diff --git a/docs/tasks/18-account-management.md b/docs/tasks/18-account-management.md index 84c6d21..ce7e1cd 100644 --- a/docs/tasks/18-account-management.md +++ b/docs/tasks/18-account-management.md @@ -121,9 +121,9 @@ External personal backups become an admin-only account-management feature. blobs, preferences JSON when present, and verification report. - [x] T18-38: Add offline snapshot verification that can run without contacting the source PDS. -- [ ] T18-39: Add tests proving a snapshot can be understood from its manifest +- [x] T18-39: Add tests proving a snapshot can be understood from its manifest and files without relying on Tempest database rows. -- [ ] T18-40: Add Mix tasks for backup, verify, list snapshots, export bundle, +- [x] T18-40: Add Mix tasks for backup, verify, list snapshots, export bundle, prune, and show account backup status. - [ ] T18-41: Add admin-only LiveView routes for external backup account list, detail, create, edit, delete, backup now, verify, prune, and export. diff --git a/lib/mix/tasks/pds.personal_backup.backup.ex b/lib/mix/tasks/pds.personal_backup.backup.ex new file mode 100644 index 0000000..d2e9cec --- /dev/null +++ b/lib/mix/tasks/pds.personal_backup.backup.ex @@ -0,0 +1,37 @@ +defmodule Mix.Tasks.Pds.PersonalBackup.Backup do + @moduledoc """ + Creates a personal backup snapshot for a registered external account. + + mix pds.personal_backup.backup --did did:plc:... + """ + + use Mix.Task + + @shortdoc "Creates a personal backup snapshot" + + @impl true + def run(args) do + Mix.Task.run("app.start") + {opts, _argv, _invalid} = OptionParser.parse(args, strict: [did: :string]) + did = Keyword.get(opts, :did) || List.first(args) + + unless did, do: Mix.raise("usage: mix pds.personal_backup.backup --did DID") + + account = account_by_did!(did) + + case Tempest.PersonalBackups.create_repo_snapshot(account) do + {:ok, %{snapshot: snapshot, run: run}} -> + Mix.shell().info( + "snapshotId=#{snapshot.id} runId=#{run.id} did=#{snapshot.did} status=#{snapshot.status} verification=#{snapshot.verification_status} storageKey=#{snapshot.storage_key}" + ) + + {:error, reason} -> + Mix.raise("personal backup failed: #{inspect(reason)}") + end + end + + defp account_by_did!(did) do + Tempest.PersonalBackups.get_account_by_did(did) || + Mix.raise("unknown personal backup account did=#{did}") + end +end diff --git a/lib/mix/tasks/pds.personal_backup.export.ex b/lib/mix/tasks/pds.personal_backup.export.ex new file mode 100644 index 0000000..8f51a77 --- /dev/null +++ b/lib/mix/tasks/pds.personal_backup.export.ex @@ -0,0 +1,31 @@ +defmodule Mix.Tasks.Pds.PersonalBackup.Export do + @moduledoc """ + Exports a personal backup snapshot as a portable zip bundle. + + mix pds.personal_backup.export --snapshot-id 123 --output /path/to/snapshot.zip + """ + + use Mix.Task + + @shortdoc "Exports a personal backup snapshot bundle" + + @impl true + def run(args) do + Mix.Task.run("app.start") + {opts, _argv, _invalid} = OptionParser.parse(args, strict: [snapshot_id: :integer, output: :string]) + snapshot_id = Keyword.get(opts, :snapshot_id) + + unless snapshot_id, do: Mix.raise("usage: mix pds.personal_backup.export --snapshot-id ID [--output PATH]") + + snapshot = Tempest.PersonalBackups.get_snapshot!(snapshot_id) + export_opts = if output = Keyword.get(opts, :output), do: [path: output], else: [] + + case Tempest.PersonalBackups.export_snapshot_bundle(snapshot, export_opts) do + {:ok, result} -> + Mix.shell().info("exportPath=#{result.path} bytes=#{result.byte_size}") + + {:error, reason} -> + Mix.raise("personal backup export failed: #{inspect(reason)}") + end + end +end diff --git a/lib/mix/tasks/pds.personal_backup.list.ex b/lib/mix/tasks/pds.personal_backup.list.ex new file mode 100644 index 0000000..2480723 --- /dev/null +++ b/lib/mix/tasks/pds.personal_backup.list.ex @@ -0,0 +1,28 @@ +defmodule Mix.Tasks.Pds.PersonalBackup.List do + @moduledoc """ + Lists personal backup snapshots. + + mix pds.personal_backup.list [--did did:plc:...] + """ + + use Mix.Task + + @shortdoc "Lists personal backup snapshots" + + @impl true + def run(args) do + Mix.Task.run("app.start") + {opts, _argv, _invalid} = OptionParser.parse(args, strict: [did: :string]) + + opts + |> Keyword.take([:did]) + |> Tempest.PersonalBackups.list_snapshots() + |> Enum.each(fn snapshot -> + completed_at = snapshot.completed_at && DateTime.to_iso8601(snapshot.completed_at) + + Mix.shell().info( + "snapshotId=#{snapshot.id} did=#{snapshot.did} status=#{snapshot.status} verification=#{snapshot.verification_status} completedAt=#{completed_at} storageKey=#{snapshot.storage_key}" + ) + end) + end +end diff --git a/lib/mix/tasks/pds.personal_backup.prune.ex b/lib/mix/tasks/pds.personal_backup.prune.ex new file mode 100644 index 0000000..49e894a --- /dev/null +++ b/lib/mix/tasks/pds.personal_backup.prune.ex @@ -0,0 +1,39 @@ +defmodule Mix.Tasks.Pds.PersonalBackup.Prune do + @moduledoc """ + Prunes personal backup snapshots according to the account retention policy. + + mix pds.personal_backup.prune --did did:plc:... + """ + + use Mix.Task + + @shortdoc "Prunes personal backup snapshots" + + @impl true + def run(args) do + Mix.Task.run("app.start") + {opts, _argv, _invalid} = OptionParser.parse(args, strict: [did: :string]) + did = Keyword.get(opts, :did) || List.first(args) + + unless did, do: Mix.raise("usage: mix pds.personal_backup.prune --did DID") + + account = account_by_did!(did) + + case Tempest.PersonalBackups.prune_snapshots(account) do + {:ok, pruned} -> + Mix.shell().info("prunedSnapshots=#{length(pruned)}") + + Enum.each(pruned, fn snapshot -> + Mix.shell().info("prunedSnapshotId=#{snapshot.id} storageKey=#{snapshot.storage_key}") + end) + + {:error, reason} -> + Mix.raise("personal backup prune failed: #{inspect(reason)}") + end + end + + defp account_by_did!(did) do + Tempest.PersonalBackups.get_account_by_did(did) || + Mix.raise("unknown personal backup account did=#{did}") + end +end diff --git a/lib/mix/tasks/pds.personal_backup.status.ex b/lib/mix/tasks/pds.personal_backup.status.ex new file mode 100644 index 0000000..2960ad9 --- /dev/null +++ b/lib/mix/tasks/pds.personal_backup.status.ex @@ -0,0 +1,47 @@ +defmodule Mix.Tasks.Pds.PersonalBackup.Status do + @moduledoc """ + Shows personal backup status for a registered external account. + + mix pds.personal_backup.status --did did:plc:... + """ + + use Mix.Task + + @shortdoc "Shows personal backup account status" + + @impl true + def run(args) do + Mix.Task.run("app.start") + {opts, _argv, _invalid} = OptionParser.parse(args, strict: [did: :string]) + did = Keyword.get(opts, :did) || List.first(args) + + unless did, do: Mix.raise("usage: mix pds.personal_backup.status --did DID") + + status = + did + |> account_by_did!() + |> Tempest.PersonalBackups.account_backup_status() + + latest_snapshot = status.latest_snapshot + latest_run = status.latest_run + + Mix.shell().info( + "did=#{status.account.did} handle=#{status.account.handle} status=#{status.account.status} credential=#{status.account.credential_state} snapshots=#{status.snapshot_count} storedBlobs=#{status.stored_blob_count}" + ) + + if latest_snapshot do + Mix.shell().info( + "latestSnapshotId=#{latest_snapshot.id} snapshotStatus=#{latest_snapshot.status} verification=#{latest_snapshot.verification_status} storageKey=#{latest_snapshot.storage_key}" + ) + end + + if latest_run do + Mix.shell().info("latestRunId=#{latest_run.id} runStatus=#{latest_run.status} kind=#{latest_run.kind}") + end + end + + defp account_by_did!(did) do + Tempest.PersonalBackups.get_account_by_did(did) || + Mix.raise("unknown personal backup account did=#{did}") + end +end diff --git a/lib/mix/tasks/pds.personal_backup.verify.ex b/lib/mix/tasks/pds.personal_backup.verify.ex new file mode 100644 index 0000000..6ec01af --- /dev/null +++ b/lib/mix/tasks/pds.personal_backup.verify.ex @@ -0,0 +1,35 @@ +defmodule Mix.Tasks.Pds.PersonalBackup.Verify do + @moduledoc """ + Verifies a personal backup snapshot offline. + + mix pds.personal_backup.verify --snapshot-id 123 + mix pds.personal_backup.verify --path /path/to/snapshot-dir + """ + + use Mix.Task + + @shortdoc "Verifies a personal backup snapshot offline" + + @impl true + def run(args) do + Mix.Task.run("app.start") + {opts, _argv, _invalid} = OptionParser.parse(args, strict: [snapshot_id: :integer, path: :string]) + + target = + cond do + snapshot_id = Keyword.get(opts, :snapshot_id) -> Tempest.PersonalBackups.get_snapshot!(snapshot_id) + path = Keyword.get(opts, :path) -> path + true -> Mix.raise("usage: mix pds.personal_backup.verify --snapshot-id ID | --path DIR") + end + + case Tempest.PersonalBackups.verify_snapshot_offline(target) do + {:ok, result} -> + account = get_in(result.manifest, ["account", "did"]) + repo = get_in(result.manifest, ["repo", "commit"]) + Mix.shell().info("verification=#{result.status} did=#{account} commit=#{repo}") + + {:error, reason} -> + Mix.raise("personal backup verify failed: #{inspect(reason)}") + end + end +end diff --git a/lib/tempest/personal_backups.ex b/lib/tempest/personal_backups.ex index 3876d41..0594350 100644 --- a/lib/tempest/personal_backups.ex +++ b/lib/tempest/personal_backups.ex @@ -35,6 +35,52 @@ defmodule Tempest.PersonalBackups do def get_account!(id), do: Repo.get!(Account, id) def get_account_by_did(did) when is_binary(did), do: Repo.get_by(Account, did: did) + def list_snapshots(opts \\ []) do + Snapshot + |> maybe_filter_snapshots_by_account(Keyword.get(opts, :account)) + |> maybe_filter_snapshots_by_did(Keyword.get(opts, :did)) + |> order_by([snapshot], desc: snapshot.completed_at, desc: snapshot.inserted_at) + |> Repo.all() + end + + def get_snapshot!(id), do: Repo.get!(Snapshot, id) + + def account_backup_status(%Account{} = account) do + latest_snapshot = + Snapshot + |> where([snapshot], snapshot.account_id == ^account.id) + |> order_by([snapshot], desc: snapshot.completed_at, desc: snapshot.inserted_at) + |> limit(1) + |> Repo.one() + + latest_run = + Run + |> where([run], run.account_id == ^account.id) + |> order_by([run], desc: run.started_at, desc: run.inserted_at) + |> limit(1) + |> Repo.one() + + snapshot_count = + Snapshot + |> where([snapshot], snapshot.account_id == ^account.id) + |> Repo.aggregate(:count) + + stored_blob_count = + from(blob in Tempest.PersonalBackups.Blob, + join: snapshot in assoc(blob, :snapshot), + where: snapshot.account_id == ^account.id and blob.status == "stored" + ) + |> Repo.aggregate(:count) + + %{ + account: account, + latest_snapshot: latest_snapshot, + latest_run: latest_run, + snapshot_count: snapshot_count, + stored_blob_count: stored_blob_count + } + end + def credential_public_state(%Account{} = account) do account |> Repo.preload(:credential, force: true) @@ -178,6 +224,18 @@ defmodule Tempest.PersonalBackups do end end + defp maybe_filter_snapshots_by_account(query, %Account{id: account_id}) do + where(query, [snapshot], snapshot.account_id == ^account_id) + end + + defp maybe_filter_snapshots_by_account(query, _account), do: query + + defp maybe_filter_snapshots_by_did(query, did) when is_binary(did) and did != "" do + where(query, [snapshot], snapshot.did == ^did) + end + + defp maybe_filter_snapshots_by_did(query, _did), do: query + def create_repo_snapshot(%Account{} = account, opts \\ []) do config = Keyword.get(opts, :config, Tempest.Config.load!()) diff --git a/test/tempest/personal_backups_test.exs b/test/tempest/personal_backups_test.exs index e972c25..cb45458 100644 --- a/test/tempest/personal_backups_test.exs +++ b/test/tempest/personal_backups_test.exs @@ -452,6 +452,30 @@ defmodule Tempest.PersonalBackupsTest do assert {:error, :sha256_mismatch} = PersonalBackups.verify_snapshot_offline(snapshot, config: config) end + test "verify_snapshot_offline understands a snapshot from manifest and files after database rows are gone" do + data_dir = Path.join(System.tmp_dir!(), "tempest_personal_backup_portable_#{System.unique_integer([:positive])}") + config = snapshot_test_config(data_dir) + on_exit(fn -> File.rm_rf(data_dir) end) + + {snapshot, _did_document} = create_no_blob_snapshot!(config) + snapshot_dir = Path.join(config.data_dir, snapshot.storage_key) + manifest_path = Path.join(snapshot_dir, "manifest.json") + repo_car_path = Path.join(snapshot_dir, "repo.car") + + Repo.delete_all(from account in Account, where: account.did == ^@did) + + refute Repo.exists?(from snapshot in Snapshot, where: snapshot.did == ^@did) + assert File.exists?(manifest_path) + assert File.exists?(repo_car_path) + + assert {:ok, %{status: "ok", manifest: manifest, report: report}} = + PersonalBackups.verify_snapshot_offline(snapshot_dir, config: config) + + assert get_in(manifest, ["account", "did"]) == @did + assert get_in(manifest, ["repo", "carPath"]) == "repo.car" + assert report["status"] == "ok" + end + test "create_repo_snapshot rejects invalid commit signatures" do data_dir = Path.join(System.tmp_dir!(), "tempest_personal_backup_bad_snapshot_#{System.unique_integer([:positive])}")