From 3fa0656da7688fd9596e395124f06bf689b4614e Mon Sep 17 00:00:00 2001 From: "Adam C. Stephens" Date: Thu, 27 Aug 2026 19:28:42 -0400 Subject: [PATCH] feat: discover a Tangled account's repos over atproto Refs: GS-26 Assisted-By: Claude Opus 5 --- config/test.exs | 2 +- lib/git_sync/connection.ex | 23 +++- lib/git_sync/connections.ex | 27 +++-- lib/git_sync/knot.ex | 4 +- lib/git_sync/knots.ex | 18 ++- lib/git_sync/tangled/client.ex | 89 ++++++++++++++- lib/git_sync/tangled/identity.ex | 94 +++++++++++++++ .../controllers/connection_controller.ex | 17 ++- .../connection_html/index.html.heex | 47 ++++++-- .../controllers/tangled_controller.ex | 6 +- ...00_add_atproto_identity_to_connections.exs | 11 ++ ...00100_remove_host_key_from_connections.exs | 9 ++ test/git_sync/connection_test.exs | 1 - test/git_sync/connections_test.exs | 104 ++++++++++++----- test/git_sync/knots_test.exs | 15 +-- test/git_sync/mirror_test.exs | 25 ++-- test/git_sync/ssh_test.exs | 19 +-- test/git_sync/tangled/client_test.exs | 101 +++++++++++++++- test/git_sync/tangled/identity_test.exs | 108 ++++++++++++++++++ .../connection_controller_test.exs | 106 ++++++++++++++--- 20 files changed, 701 insertions(+), 125 deletions(-) create mode 100644 lib/git_sync/tangled/identity.ex create mode 100644 priv/repo/migrations/20260827200000_add_atproto_identity_to_connections.exs create mode 100644 priv/repo/migrations/20260827200100_remove_host_key_from_connections.exs create mode 100644 test/git_sync/tangled/identity_test.exs diff --git a/config/test.exs b/config/test.exs index becaf06..b95b6f9 100644 --- a/config/test.exs +++ b/config/test.exs @@ -39,7 +39,7 @@ config :git_sync, GitSync.Vault, ] # Outbound HTTP is stubbed per test process; see `Req.Test`. -config :git_sync, :req_options, plug: {Req.Test, GitSync.Http} +config :git_sync, :req_options, plug: {Req.Test, GitSync.Http}, retry: false # The sync runners are started per test, not at boot. config :git_sync, start_runners: false diff --git a/lib/git_sync/connection.ex b/lib/git_sync/connection.ex index 44ac5e8..ef7d853 100644 --- a/lib/git_sync/connection.ex +++ b/lib/git_sync/connection.ex @@ -9,6 +9,8 @@ defmodule GitSync.Connection do @kinds [:forgejo, :github, :tangled] + @appview "https://tangled.org" + schema "connections" do field :kind, Ecto.Enum, values: @kinds field :base_url, :string @@ -18,7 +20,9 @@ defmodule GitSync.Connection do field :subject, :string field :ssh_key, GitSync.Encrypted.Binary, redact: true field :public_key, :string - field :host_key, :string + field :did, :string + field :handle, :string + field :pds_url, :string field :client_id, :string field :client_secret, GitSync.Encrypted.Binary, redact: true field :operator, :string @@ -36,6 +40,23 @@ defmodule GitSync.Connection do |> unique_constraint(:kind) end + @doc """ + Changeset for a Tangled connection. The operator supplies the account and, + at most, the appview to reach its knots through; the identity fields are + resolved from the account rather than typed. + """ + def tangled_changeset(connection, attrs) do + base_url = attrs |> Map.get("base_url", "") |> String.trim() |> appview() + + connection + |> changeset(Map.put(attrs, "base_url", base_url)) + |> cast(attrs, [:handle]) + |> validate_required([:handle]) + end + + defp appview(""), do: @appview + defp appview(base_url), do: base_url + @doc """ Changeset for a forge whose credential is obtained over OAuth. """ diff --git a/lib/git_sync/connections.ex b/lib/git_sync/connections.ex index b7041ee..db93e84 100644 --- a/lib/git_sync/connections.ex +++ b/lib/git_sync/connections.ex @@ -9,6 +9,7 @@ defmodule GitSync.Connections do alias GitSync.Forge.Token alias GitSync.Repo alias GitSync.Ssh + alias GitSync.Tangled.Identity def list, do: Repo.all(from c in Connection, order_by: [asc: c.id]) @@ -65,22 +66,26 @@ defmodule GitSync.Connections do end @doc """ - Saves the knot's URL, scanning it for the host keys git-sync should pin. The - keypair is left alone; see `generate_tangled_key/1`. + Saves the Tangled account whose repositories may be mirrored, resolving it to + the DID and PDS the repository records are read from. The keypair is left + alone; see `generate_tangled_key/1`. + + Nothing is scanned here. Every knot a repository names is pinned the first + time git-sync pushes to it, so the account is the only thing to settle. """ def configure_tangled(attrs) do changeset = (tangled() || %Connection{}) - |> Connection.changeset(Map.put(attrs, "kind", :tangled)) + |> Connection.tangled_changeset(Map.put(attrs, "kind", :tangled)) with %Ecto.Changeset{valid?: true} <- changeset, - {:ok, host_key} <- scan(changeset) do + {:ok, identity} <- Identity.resolve(Ecto.Changeset.get_field(changeset, :handle)) do changeset - |> Ecto.Changeset.put_change(:host_key, host_key) + |> Ecto.Changeset.change(identity) |> Repo.insert_or_update() else %Ecto.Changeset{} -> {:error, Map.put(changeset, :action, :insert)} - {:error, reason} -> {:error, base_url_error(changeset, reason)} + {:error, reason} -> {:error, handle_error(changeset, reason)} end end @@ -100,15 +105,9 @@ defmodule GitSync.Connections do end end - defp scan(changeset) do - %URI{host: host} = URI.parse(Ecto.Changeset.get_field(changeset, :base_url)) - - Ssh.scan_host(host) - end - - defp base_url_error(changeset, reason) do + defp handle_error(changeset, reason) do changeset - |> Ecto.Changeset.add_error(:base_url, reason) + |> Ecto.Changeset.add_error(:handle, reason) |> Map.put(:action, :insert) end diff --git a/lib/git_sync/knot.ex b/lib/git_sync/knot.ex index 77ee43f..a852d25 100644 --- a/lib/git_sync/knot.ex +++ b/lib/git_sync/knot.ex @@ -3,8 +3,8 @@ defmodule GitSync.Knot do The host keys pinned for one knot a connection pushes to. A Tangled account's repositories can be spread over several knots, and a - host key belongs to the host rather than the account, so the connection's - own `host_key` only covers the knot its `base_url` names. + host key belongs to the host rather than the account, so there is nowhere on + the connection itself for one to live. """ use Ecto.Schema diff --git a/lib/git_sync/knots.ex b/lib/git_sync/knots.ex index 15af4f5..7e09b53 100644 --- a/lib/git_sync/knots.ex +++ b/lib/git_sync/knots.ex @@ -3,24 +3,30 @@ defmodule GitSync.Knots do The host keys to verify a push against, pinned per knot. """ + import Ecto.Query + alias GitSync.Connection alias GitSync.Knot alias GitSync.Repo alias GitSync.Ssh alias GitSync.Tangled.Client + @doc """ + Every knot pinned for a connection, oldest first. + """ + def list(%Connection{} = connection), + do: Repo.all(from k in Knot, where: k.connection_id == ^connection.id, order_by: [asc: k.id]) + @doc """ The `known_hosts` blob for the host holding `repo`, scanning and pinning it - the first time a repo names a knot nothing has been pinned for. + the first time anything is pushed to a knot. A repo that names no knot is + taken to be on the appview's own, which is the one Tangled hosts itself. """ def host_key(%Connection{kind: :tangled} = connection, repo) do - case Client.knot_host(connection, repo) do - nil -> {:ok, connection.host_key} - host -> pinned(connection, host) - end + pinned(connection, Client.knot_host(connection, repo) || URI.parse(connection.base_url).host) end - def host_key(%Connection{host_key: host_key}, _repo), do: {:ok, host_key} + def host_key(%Connection{}, _repo), do: {:ok, nil} defp pinned(%Connection{} = connection, host) do case Repo.get_by(Knot, connection_id: connection.id, host: host) do diff --git a/lib/git_sync/tangled/client.ex b/lib/git_sync/tangled/client.ex index a65d999..224efbd 100644 --- a/lib/git_sync/tangled/client.ex +++ b/lib/git_sync/tangled/client.ex @@ -1,15 +1,37 @@ defmodule GitSync.Tangled.Client do @moduledoc """ - Tangled knots speak plain git and nothing else: no repository listing and no - webhooks, so a Tangled source is driven entirely by its timer. + Tangled knots speak plain git and nothing else, so a Tangled source is driven + entirely by its timer. The repository list does not come from a knot at all: + every repo an account owns is a public atproto record in its PDS, so listing + needs no credential. """ @behaviour GitSync.Forge alias GitSync.Connection + @collection "sh.tangled.repo" + @page_size 100 + @max_pages 20 + + @doc """ + The repositories the account owns, wherever they are knotted. Repos it only + collaborates on are not here: those records live in the owner's PDS, and + finding them would need an index the appview does not publish. + """ @impl GitSync.Forge - def list_repos(%Connection{}), do: {:error, :unsupported} + def list_repos(%Connection{did: nil}), + do: {:error, "Save the Tangled account again to list its repositories"} + + def list_repos(%Connection{} = connection) do + case records(connection, nil, 1, []) do + {:ok, records} -> + {:ok, records |> Enum.filter(&routable?/1) |> Enum.map(&repo(connection, &1))} + + {:error, reason} -> + {:error, reason} + end + end @impl GitSync.Forge def clone_url(%Connection{base_url: base_url}, repo, :read) do @@ -42,6 +64,67 @@ defmodule GitSync.Tangled.Client do end end + defp records(_connection, _cursor, page, acc) when page > @max_pages, do: {:ok, acc} + + defp records(%Connection{} = connection, cursor, page, acc) do + request = + GitSync.Http.request( + base_url: String.trim_trailing(connection.pds_url, "/"), + url: "/xrpc/com.atproto.repo.listRecords", + params: [repo: connection.did, collection: @collection, limit: @page_size] ++ from(cursor) + ) + + case Req.get(request) do + {:ok, %Req.Response{status: 200, body: %{"records" => records, "cursor" => cursor}}} -> + records(connection, cursor, page + 1, acc ++ records) + + {:ok, %Req.Response{status: 200, body: %{"records" => records}}} -> + {:ok, acc ++ records} + + {:ok, %Req.Response{status: status}} -> + {:error, "The repository server returned HTTP #{status}"} + + {:error, exception} -> + {:error, Exception.message(exception)} + end + end + + defp from(nil), do: [] + defp from(cursor), do: [cursor: cursor] + + defp repo(%Connection{} = connection, %{"uri" => uri} = record) do + path = connection.handle <> "/" <> String.replace(uri, ~r{.*/}, "") + + %{ + full_name: host(record) <> "/" <> path, + clone_url: String.trim_trailing(connection.base_url, "/") <> "/" <> path, + private: false + } + end + + defp routable?(record), do: reachable?(host(record)) + + defp host(%{"value" => %{"knot" => knot}}), do: knot |> String.split(":") |> hd() + + defp reachable?(host) do + cond do + not String.contains?(host, ".") -> false + String.ends_with?(host, [".local", ".localhost"]) -> false + true -> not private_address?(host) + end + end + + defp private_address?(host) do + case :inet.parse_ipv4strict_address(String.to_charlist(host)) do + {:ok, {127, _, _, _}} -> true + {:ok, {10, _, _, _}} -> true + {:ok, {192, 168, _, _}} -> true + {:ok, {169, 254, _, _}} -> true + {:ok, {172, second, _, _}} -> second in 16..31 + _ -> false + end + end + @impl GitSync.Forge def create_webhook(%Connection{}, _repo, _url, _secret), do: {:error, :unsupported} diff --git a/lib/git_sync/tangled/identity.ex b/lib/git_sync/tangled/identity.ex new file mode 100644 index 0000000..4151a3e --- /dev/null +++ b/lib/git_sync/tangled/identity.ex @@ -0,0 +1,94 @@ +defmodule GitSync.Tangled.Identity do + @moduledoc """ + Turns whatever an operator types for their Tangled account into the three + facts listing their repositories needs: the DID that owns the records, the + handle their repository paths are written with, and the PDS holding them. + + Tangled's appview publishes no handle resolution of its own, so a handle goes + through the Bluesky appview. A DID skips that and goes straight to the + directory. + """ + + @resolver "https://public.api.bsky.app" + @directory "https://plc.directory" + + @doc """ + Resolves a handle or DID. Nothing here is cached: the caller stores what + comes back, and an account that moves PDS or renames is re-resolved by + saving the connection again. + """ + def resolve(account) do + with {:ok, did} <- did(String.trim_leading(String.trim(account), "@")), + {:ok, document} <- document(did) do + identity(did, document) + else + {:error, reason} -> {:error, reason} + end + end + + defp did("did:" <> _ = did), do: {:ok, did} + + defp did(handle) do + request = + GitSync.Http.request( + base_url: @resolver, + url: "/xrpc/com.atproto.identity.resolveHandle", + params: [handle: handle] + ) + + case Req.get(request) do + {:ok, %Req.Response{status: 200, body: %{"did" => did}}} -> {:ok, did} + {:ok, %Req.Response{}} -> {:error, "No account could be found for #{handle}"} + {:error, exception} -> {:error, Exception.message(exception)} + end + end + + defp document(did) do + request = GitSync.Http.request(base_url: @directory, url: "/#{did}") + + case Req.get(request) do + {:ok, %Req.Response{status: 200, body: body}} -> + document(body, did) + + {:ok, %Req.Response{status: status}} -> + {:error, "The directory returned HTTP #{status} for #{did}"} + + {:error, exception} -> + {:error, Exception.message(exception)} + end + end + + defp document(document, _did) when is_map(document), do: {:ok, document} + + defp document(body, did) do + case JSON.decode(body) do + {:ok, document} when is_map(document) -> {:ok, document} + _undecodable -> {:error, "The directory holds no DID document for #{did}"} + end + end + + defp identity(did, document) do + case pds(document) do + nil -> {:error, "#{did} has no atproto PDS to list repositories from"} + pds -> {:ok, %{did: did, handle: handle(document), pds_url: pds}} + end + end + + defp pds(document) do + document + |> Map.get("service", []) + |> Enum.find_value(fn + %{"type" => "AtprotoPersonalDataServer", "serviceEndpoint" => endpoint} -> endpoint + _ -> nil + end) + end + + defp handle(document) do + document + |> Map.get("alsoKnownAs", []) + |> Enum.find_value(fn + "at://" <> handle -> handle + _ -> nil + end) + end +end diff --git a/lib/git_sync_web/controllers/connection_controller.ex b/lib/git_sync_web/controllers/connection_controller.ex index 992a8e2..d08159d 100644 --- a/lib/git_sync_web/controllers/connection_controller.ex +++ b/lib/git_sync_web/controllers/connection_controller.ex @@ -6,6 +6,7 @@ defmodule GitSyncWeb.ConnectionController do alias GitSync.Connection alias GitSync.Connections alias GitSync.Forge + alias GitSync.Knots alias GitSync.Ssh def index(conn, _params), do: render_index(conn) @@ -29,8 +30,9 @@ defmodule GitSyncWeb.ConnectionController do github_repos: github_repos(github), github_form: form(Connection.oauth_changeset(%Connection{}, %{})), tangled: tangled, - tangled_fingerprints: fingerprints(tangled), - tangled_form: form(Connection.changeset(tangled || %Connection{}, %{})) + tangled_knots: knots(tangled), + tangled_repos: tangled_repos(tangled), + tangled_form: form(Connection.tangled_changeset(tangled || %Connection{}, %{})) ], overrides ) @@ -42,8 +44,15 @@ defmodule GitSyncWeb.ConnectionController do def form(changeset), do: to_form(changeset) - defp fingerprints(%Connection{host_key: host_key}), do: Ssh.fingerprints(host_key) - defp fingerprints(nil), do: [] + defp knots(%Connection{} = tangled) do + for knot <- Knots.list(tangled), + do: %{host: knot.host, fingerprints: Ssh.fingerprints(knot.host_key)} + end + + defp knots(nil), do: [] + + defp tangled_repos(%Connection{} = tangled), do: Forge.list_repos(tangled) + defp tangled_repos(nil), do: nil defp github_repos(%Connection{token: token} = github) when is_binary(token), do: Forge.list_repos(github) diff --git a/lib/git_sync_web/controllers/connection_html/index.html.heex b/lib/git_sync_web/controllers/connection_html/index.html.heex index 4d72039..f171916 100644 --- a/lib/git_sync_web/controllers/connection_html/index.html.heex +++ b/lib/git_sync_web/controllers/connection_html/index.html.heex @@ -59,33 +59,58 @@ <.header> Tangled <:subtitle> - A knot speaks plain git. git-sync scans the knot for its host keys and - generates the key it pushes with. + Repositories are public atproto records, so git-sync lists them from the + account that owns them. Pushing needs a key each knot has been given. - <.form for={@tangled_form} id="tangled-form" action={~p"/connections/tangled"}> + <.form for={@tangled_form} id="tangled-form" action={~p"/connections/tangled"} method="post"> + <.input field={@tangled_form[:handle]} label="Handle or DID" placeholder="alice.example.com" /> <.input field={@tangled_form[:base_url]} - label="Knot URL" - placeholder="https://knot.example.com" + label="Appview URL" + placeholder="https://tangled.org" /> - <.button id="save-tangled" type="submit">Save knot + <.button id="save-tangled" type="submit">Save account <%= if @tangled do %> <.list> - <:item title="Host keys"> -

{fingerprint}

-

Check these against the fingerprints the knot's operator publishes.

- + <:item title="Account">{@tangled.handle} + <:item title="DID">{@tangled.did} + <:item title="Appview">{@tangled.base_url} + <%= case @tangled_repos do %> + <% {:ok, repos} -> %> + + <% {:error, reason} -> %> +

Could not list Tangled repositories: {reason}

+ <% nil -> %> + <% end %> + + <%= if @tangled_knots != [] do %> + <.header> + Knot host keys + <:subtitle> + Check these against the fingerprints each knot's operator publishes. + + + + <.list> + <:item :for={knot <- @tangled_knots} title={knot.host}> +

{fingerprint}

+ + + <% end %> + <%= if @tangled.public_key do %> <.header> Public key - <:subtitle>Add this to the knot so git-sync may push to it. + <:subtitle>Add this to each knot so git-sync may push to it.
<%= @tangled.public_key %>
diff --git a/lib/git_sync_web/controllers/tangled_controller.ex b/lib/git_sync_web/controllers/tangled_controller.ex index 5c261a4..589a5db 100644 --- a/lib/git_sync_web/controllers/tangled_controller.ex +++ b/lib/git_sync_web/controllers/tangled_controller.ex @@ -11,7 +11,7 @@ defmodule GitSyncWeb.TangledController do case Connections.configure_tangled(params) do {:ok, _connection} -> conn - |> put_flash(:info, "Knot saved. Generate a key and add it to the knot to push.") + |> put_flash(:info, "Account saved. Generate a key and add it to your knots to push.") |> redirect(to: ~p"/connections") {:error, changeset} -> @@ -25,7 +25,7 @@ defmodule GitSyncWeb.TangledController do case Connections.generate_tangled_key(tangled) do {:ok, _connection} -> conn - |> put_flash(:info, "Key generated. Add the public key to the knot.") + |> put_flash(:info, "Key generated. Add the public key to your knots.") |> redirect(to: ~p"/connections") {:error, reason} -> @@ -42,7 +42,7 @@ defmodule GitSyncWeb.TangledController do nil -> conn - |> put_flash(:error, "No knot is configured yet.") + |> put_flash(:error, "No Tangled account is configured yet.") |> redirect(to: ~p"/connections") |> halt() end diff --git a/priv/repo/migrations/20260827200000_add_atproto_identity_to_connections.exs b/priv/repo/migrations/20260827200000_add_atproto_identity_to_connections.exs new file mode 100644 index 0000000..2900319 --- /dev/null +++ b/priv/repo/migrations/20260827200000_add_atproto_identity_to_connections.exs @@ -0,0 +1,11 @@ +defmodule GitSync.Repo.Migrations.AddAtprotoIdentityToConnections do + use Ecto.Migration + + def change do + alter table(:connections) do + add :did, :string + add :handle, :string + add :pds_url, :string + end + end +end diff --git a/priv/repo/migrations/20260827200100_remove_host_key_from_connections.exs b/priv/repo/migrations/20260827200100_remove_host_key_from_connections.exs new file mode 100644 index 0000000..bf8e263 --- /dev/null +++ b/priv/repo/migrations/20260827200100_remove_host_key_from_connections.exs @@ -0,0 +1,9 @@ +defmodule GitSync.Repo.Migrations.RemoveHostKeyFromConnections do + use Ecto.Migration + + def change do + alter table(:connections) do + remove :host_key, :string + end + end +end diff --git a/test/git_sync/connection_test.exs b/test/git_sync/connection_test.exs index ff225f5..e78720d 100644 --- a/test/git_sync/connection_test.exs +++ b/test/git_sync/connection_test.exs @@ -41,7 +41,6 @@ defmodule GitSync.ConnectionTest do kind: :tangled, base_url: "https://knot.example.com", ssh_key: "-----BEGIN OPENSSH PRIVATE KEY-----", - host_key: "knot.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample", public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample" }) diff --git a/test/git_sync/connections_test.exs b/test/git_sync/connections_test.exs index 54b882e..e8c1348 100644 --- a/test/git_sync/connections_test.exs +++ b/test/git_sync/connections_test.exs @@ -3,8 +3,6 @@ defmodule GitSync.ConnectionsTest do alias GitSync.Connections alias GitSync.Forge.Token - alias GitSync.KnotServer - alias GitSync.Ssh @attrs %{ "base_url" => "https://codeberg.org", @@ -66,54 +64,86 @@ defmodule GitSync.ConnectionsTest do describe "tangled" do setup do - %{public: public} = KnotServer.serve() - - %{host_public: public} + Req.Test.stub(GitSync.Http, fn conn -> + case conn.host do + "public.api.bsky.app" -> Req.Test.json(conn, %{"did" => "did:plc:abc"}) + "plc.directory" -> Req.Test.json(conn, did_doc()) + end + end) end - test "configuring a knot scans the host for its keys" do + test "configuring an account resolves the identity its repositories hang off" do refute Connections.tangled() - assert {:ok, connection} = - Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) + assert {:ok, connection} = Connections.configure_tangled(%{"handle" => "oppi.li"}) assert connection.kind == :tangled - assert connection.host_key =~ "ssh-ed25519 " + assert connection.did == "did:plc:abc" + assert connection.handle == "oppi.li" + assert connection.pds_url == "https://pds.example" assert Connections.tangled().id == connection.id end - test "the scanned keys are the ones the host actually offers", %{host_public: public} do - {:ok, connection} = Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) + test "the appview is tangled.org unless another one is given" do + assert {:ok, connection} = Connections.configure_tangled(%{"handle" => "oppi.li"}) + + assert connection.base_url == "https://tangled.org" + end + + test "an operator running their own appview may name it" do + assert {:ok, connection} = + Connections.configure_tangled(%{ + "handle" => "oppi.li", + "base_url" => "https://tangled.example" + }) + + assert connection.base_url == "https://tangled.example" + end + + test "a blank appview is the default rather than an invalid URL" do + assert {:ok, connection} = + Connections.configure_tangled(%{"handle" => "oppi.li", "base_url" => ""}) + + assert connection.base_url == "https://tangled.org" + end + + test "the handle is stored as the directory spells it, not as it was typed" do + assert {:ok, connection} = Connections.configure_tangled(%{"handle" => "@oppi.li"}) - assert Ssh.fingerprints(connection.host_key) == [KnotServer.fingerprint(public)] + assert connection.handle == "oppi.li" end - test "configuring twice rescans and updates the single row" do - {:ok, first} = Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) - {:ok, second} = Connections.configure_tangled(%{"base_url" => "https://127.0.0.1/"}) + test "configuring twice re-resolves and updates the single row" do + {:ok, first} = Connections.configure_tangled(%{"handle" => "oppi.li"}) + {:ok, second} = Connections.configure_tangled(%{"handle" => "did:plc:abc"}) assert first.id == second.id - assert second.base_url == "https://127.0.0.1/" end - test "a host that cannot be scanned is an error on the URL" do - KnotServer.refuse() + test "an account that cannot be resolved is an error on the handle" do + Req.Test.stub(GitSync.Http, &Plug.Conn.send_resp(&1, 400, "")) - assert {:error, changeset} = - Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) + assert {:error, changeset} = Connections.configure_tangled(%{"handle" => "nobody.example"}) - assert %{base_url: ["no host keys came back" <> _]} = errors_on(changeset) + assert %{handle: ["No account could be found for nobody.example"]} = errors_on(changeset) refute Connections.tangled() end - test "a base_url that is not a URL never reaches the network" do - assert {:error, changeset} = Connections.configure_tangled(%{"base_url" => "not a url"}) + test "an account nobody typed never reaches the network" do + assert {:error, changeset} = Connections.configure_tangled(%{"handle" => ""}) + + assert %{handle: ["can't be blank"]} = errors_on(changeset) + end + + test "an appview that is not a URL never reaches the network" do + assert {:error, changeset} = + Connections.configure_tangled(%{"handle" => "oppi.li", "base_url" => "not a url"}) assert %{base_url: ["must be an http or https URL"]} = errors_on(changeset) end test "generating a key stores the private half and exposes the public half" do - {:ok, connection} = Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) + {:ok, connection} = Connections.configure_tangled(%{"handle" => "oppi.li"}) assert {:ok, connection} = Connections.generate_tangled_key(connection) @@ -122,7 +152,7 @@ defmodule GitSync.ConnectionsTest do end test "generating a key again replaces the old one" do - {:ok, connection} = Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) + {:ok, connection} = Connections.configure_tangled(%{"handle" => "oppi.li"}) {:ok, first} = Connections.generate_tangled_key(connection) {:ok, second} = Connections.generate_tangled_key(first) @@ -130,14 +160,28 @@ defmodule GitSync.ConnectionsTest do refute second.ssh_key == first.ssh_key end - test "rescanning a knot leaves its key alone" do - {:ok, connection} = Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) + test "re-resolving an account leaves its key alone" do + {:ok, connection} = Connections.configure_tangled(%{"handle" => "oppi.li"}) {:ok, connection} = Connections.generate_tangled_key(connection) - {:ok, rescanned} = Connections.configure_tangled(%{"base_url" => "https://127.0.0.1"}) + {:ok, resolved} = Connections.configure_tangled(%{"handle" => "oppi.li"}) + + assert resolved.ssh_key == connection.ssh_key + assert resolved.public_key == connection.public_key + end - assert rescanned.ssh_key == connection.ssh_key - assert rescanned.public_key == connection.public_key + defp did_doc do + %{ + "id" => "did:plc:abc", + "alsoKnownAs" => ["at://oppi.li"], + "service" => [ + %{ + "id" => "#atproto_pds", + "type" => "AtprotoPersonalDataServer", + "serviceEndpoint" => "https://pds.example" + } + ] + } end end diff --git a/test/git_sync/knots_test.exs b/test/git_sync/knots_test.exs index 89fc212..cd0d3db 100644 --- a/test/git_sync/knots_test.exs +++ b/test/git_sync/knots_test.exs @@ -10,18 +10,19 @@ defmodule GitSync.KnotsTest do setup do connection = - Repo.insert!(%Connection{ - kind: :tangled, - base_url: "https://tangled.org", - host_key: "tangled.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIdefault" - }) + Repo.insert!(%Connection{kind: :tangled, base_url: "https://127.0.0.1"}) %{connection: connection} end describe "host_key/2" do - test "uses the connection's own key for a repo on its knot", %{connection: connection} do - assert Knots.host_key(connection, "adam/git-sync") == {:ok, connection.host_key} + test "pins the appview for a repo that names no knot", %{connection: connection} do + %{public: public} = KnotServer.serve() + + assert {:ok, host_key} = Knots.host_key(connection, "adam/git-sync") + assert Ssh.fingerprints(host_key) == [KnotServer.fingerprint(public)] + + assert %Knot{} = Repo.get_by(Knot, connection_id: connection.id, host: "127.0.0.1") end test "scans and pins a knot named by the repo", %{connection: connection} do diff --git a/test/git_sync/mirror_test.exs b/test/git_sync/mirror_test.exs index bae4c35..5417997 100644 --- a/test/git_sync/mirror_test.exs +++ b/test/git_sync/mirror_test.exs @@ -205,13 +205,10 @@ defmodule GitSync.MirrorTest do test "pushes to a knot over ssh", %{forge: forge} do source = source(forge) - knot = - knot_connection(forge, - base_url: "https://knot.invalid", - host_key: "knot.invalid ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample" - ) + knot = knot_connection(forge, base_url: "https://knot.invalid") repoint(source, knot) + pin(knot, "knot.invalid") {:error, run} = Mirror.sync(reload(source)) @@ -225,16 +222,10 @@ defmodule GitSync.MirrorTest do test "pushes to the knot a repo names rather than the connection's", %{forge: forge} do source = source(forge) - knot = - knot_connection(forge, - base_url: "https://tangled.org", - host_key: "tangled.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIdefault" - ) + knot = knot_connection(forge, base_url: "https://tangled.org") repoint(source, knot, "git.invalid/adam/git-sync") - - pinned = "git.invalid ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIpinned" - Repo.insert!(%Knot{connection_id: knot.id, host: "git.invalid", host_key: pinned}) + pin(knot, "git.invalid") {:error, run} = Mirror.sync(reload(source)) @@ -342,6 +333,14 @@ defmodule GitSync.MirrorTest do |> Repo.update!() end + defp pin(%Connection{} = knot, host) do + Repo.insert!(%Knot{ + connection_id: knot.id, + host: host, + host_key: "#{host} ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample" + }) + end + defp knot_connection(forge, attrs) do Repo.insert!( struct( diff --git a/test/git_sync/ssh_test.exs b/test/git_sync/ssh_test.exs index bedc485..bc3e5e8 100644 --- a/test/git_sync/ssh_test.exs +++ b/test/git_sync/ssh_test.exs @@ -17,25 +17,26 @@ defmodule GitSync.SshTest do connection: %Connection{ kind: :tangled, base_url: "https://knot.example.com", - ssh_key: File.read!(key), - host_key: "knot.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample" + ssh_key: File.read!(key) }, fingerprint: fingerprint(key <> ".pub") } end + @host_key "knot.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample" + describe "with_agent/3" do test "loads the key into an agent the block can reach", context do %{connection: connection, fingerprint: fingerprint} = context - {:ok, identities} = Ssh.with_agent(connection, connection.host_key, &ssh_add_list/1) + {:ok, identities} = Ssh.with_agent(connection, @host_key, &ssh_add_list/1) assert identities =~ fingerprint end test "verifies the host against a private known_hosts file", %{connection: connection} do {:ok, command} = - Ssh.with_agent(connection, connection.host_key, fn env -> + Ssh.with_agent(connection, @host_key, fn env -> command = env(env, "GIT_SSH_COMMAND") known_hosts = option(command, "UserKnownHostsFile") @@ -47,7 +48,7 @@ defmodule GitSync.SshTest do assert command =~ "GlobalKnownHostsFile=/dev/null" assert_received {:known_hosts, contents, %File.Stat{mode: mode}} - assert contents == connection.host_key <> "\n" + assert contents == @host_key <> "\n" assert Bitwise.band(mode, 0o777) == 0o600 end @@ -67,7 +68,7 @@ defmodule GitSync.SshTest do test "stops the agent when the block raises", %{connection: connection} do assert_raise RuntimeError, fn -> - Ssh.with_agent(connection, connection.host_key, fn env -> + Ssh.with_agent(connection, @host_key, fn env -> send(self(), {:env, env}) raise "boom" end) @@ -79,7 +80,7 @@ defmodule GitSync.SshTest do test "keeps the agent's paths out of the block's output", %{connection: connection} do {:error, output} = - Ssh.with_agent(connection, connection.host_key, fn env -> + Ssh.with_agent(connection, @host_key, fn env -> {:error, env(env, "SSH_AUTH_SOCK")} end) @@ -90,7 +91,7 @@ defmodule GitSync.SshTest do connection = %Connection{kind: :forgejo, base_url: "https://codeberg.org"} assert {:ok, "[]"} == - Ssh.with_agent(connection, connection.host_key, fn env -> + Ssh.with_agent(connection, @host_key, fn env -> {:ok, "#{inspect(env)}"} end) end @@ -152,7 +153,7 @@ defmodule GitSync.SshTest do defp captured_env(connection) do {:ok, ""} = - Ssh.with_agent(connection, connection.host_key, fn env -> + Ssh.with_agent(connection, @host_key, fn env -> send(self(), {:env, env}) {:ok, ""} end) diff --git a/test/git_sync/tangled/client_test.exs b/test/git_sync/tangled/client_test.exs index d5bc082..a6c45e3 100644 --- a/test/git_sync/tangled/client_test.exs +++ b/test/git_sync/tangled/client_test.exs @@ -6,8 +6,105 @@ defmodule GitSync.Tangled.ClientTest do @connection %Connection{kind: :tangled, base_url: "https://knot.example/"} - test "has no repository listing to offer" do - assert {:error, :unsupported} = Client.list_repos(@connection) + @account %Connection{ + kind: :tangled, + base_url: "https://tangled.org", + did: "did:plc:abc", + handle: "oppi.li", + pds_url: "https://pds.example" + } + + defp stub(fun), do: Req.Test.stub(GitSync.Http, fun) + + defp record(name, knot), + do: %{ + "uri" => "at://did:plc:abc/sh.tangled.repo/#{name}", + "value" => %{"$type" => "sh.tangled.repo", "knot" => knot} + } + + describe "list_repos/1" do + test "lists the repository records the account owns" do + stub(fn conn -> + assert conn.host == "pds.example" + assert conn.request_path == "/xrpc/com.atproto.repo.listRecords" + + conn = Plug.Conn.fetch_query_params(conn) + + assert conn.query_params["repo"] == "did:plc:abc" + assert conn.query_params["collection"] == "sh.tangled.repo" + + Req.Test.json(conn, %{"records" => [record("git-sync", "knot1.tangled.sh")]}) + end) + + assert {:ok, [repo]} = Client.list_repos(@account) + + assert repo == %{ + full_name: "knot1.tangled.sh/oppi.li/git-sync", + clone_url: "https://tangled.org/oppi.li/git-sync", + private: false + } + end + + test "follows the cursor until the records run out" do + stub(fn conn -> + conn = Plug.Conn.fetch_query_params(conn) + + case conn.query_params["cursor"] do + nil -> + Req.Test.json(conn, %{ + "records" => [record("first", "knot1.tangled.sh")], + "cursor" => "3m37uwfvtlp22" + }) + + "3m37uwfvtlp22" -> + Req.Test.json(conn, %{"records" => [record("last", "knot1.tangled.sh")]}) + end + end) + + assert {:ok, repos} = Client.list_repos(@account) + + assert Enum.map(repos, & &1.full_name) == [ + "knot1.tangled.sh/oppi.li/first", + "knot1.tangled.sh/oppi.li/last" + ] + end + + test "omits repositories on knots nothing outside the operator's machine can reach" do + stub(fn conn -> + Req.Test.json(conn, %{ + "records" => [ + record("shipped", "knot1.tangled.sh"), + record("scratch", "localhost:6444"), + record("laptop", "orion.local:5555"), + record("lan", "192.168.1.20:5555") + ] + }) + end) + + assert {:ok, repos} = Client.list_repos(@account) + + assert Enum.map(repos, & &1.full_name) == ["knot1.tangled.sh/oppi.li/shipped"] + end + + test "drops the port a knot serves git over, which is not the port it is pushed to" do + stub(fn conn -> + Req.Test.json(conn, %{"records" => [record("git-sync", "knot.example.com:5555")]}) + end) + + assert {:ok, [%{full_name: "knot.example.com/oppi.li/git-sync"}]} = + Client.list_repos(@account) + end + + test "asks for an account that predates identity resolution to be saved again" do + assert {:error, "Save the Tangled account again to list its repositories"} = + Client.list_repos(@connection) + end + + test "reports a repository server that will not answer" do + stub(fn conn -> Plug.Conn.send_resp(conn, 502, "") end) + + assert {:error, "The repository server returned HTTP 502"} = Client.list_repos(@account) + end end test "reads over https" do diff --git a/test/git_sync/tangled/identity_test.exs b/test/git_sync/tangled/identity_test.exs new file mode 100644 index 0000000..ad3d223 --- /dev/null +++ b/test/git_sync/tangled/identity_test.exs @@ -0,0 +1,108 @@ +defmodule GitSync.Tangled.IdentityTest do + use ExUnit.Case, async: true + + alias GitSync.Tangled.Identity + + defp stub(fun), do: Req.Test.stub(GitSync.Http, fun) + + defp did_doc(pds) do + %{ + "id" => "did:plc:abc", + "alsoKnownAs" => ["at://oppi.li"], + "service" => [ + %{"id" => "#atproto_pds", "type" => "AtprotoPersonalDataServer", "serviceEndpoint" => pds} + ] + } + end + + defp directory(conn), do: Req.Test.json(conn, did_doc("https://pds.example")) + + test "resolves a handle to its did, canonical handle and pds" do + stub(fn conn -> + case {conn.host, conn.request_path} do + {"public.api.bsky.app", "/xrpc/com.atproto.identity.resolveHandle"} -> + conn = Plug.Conn.fetch_query_params(conn) + assert conn.query_params["handle"] == "oppi.li" + + Req.Test.json(conn, %{"did" => "did:plc:abc"}) + + {"plc.directory", "/did:plc:abc"} -> + directory(conn) + end + end) + + assert {:ok, identity} = Identity.resolve("oppi.li") + + assert identity == %{ + did: "did:plc:abc", + handle: "oppi.li", + pds_url: "https://pds.example" + } + end + + test "a did needs no handle lookup" do + stub(fn conn -> + assert conn.host == "plc.directory" + + directory(conn) + end) + + assert {:ok, %{did: "did:plc:abc", handle: "oppi.li"}} = Identity.resolve("did:plc:abc") + end + + test "a handle may be written the way Tangled displays it" do + stub(fn conn -> + case conn.host do + "public.api.bsky.app" -> + conn = Plug.Conn.fetch_query_params(conn) + assert conn.query_params["handle"] == "oppi.li" + + Req.Test.json(conn, %{"did" => "did:plc:abc"}) + + "plc.directory" -> + directory(conn) + end + end) + + assert {:ok, %{handle: "oppi.li"}} = Identity.resolve("@oppi.li") + end + + test "reads a DID document the directory serves as linked data" do + stub(fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/did+ld+json") + |> Plug.Conn.send_resp(200, JSON.encode!(did_doc("https://pds.example"))) + end) + + assert {:ok, %{pds_url: "https://pds.example"}} = Identity.resolve("did:plc:abc") + end + + test "reports a handle nobody answers for" do + stub(fn conn -> Plug.Conn.send_resp(conn, 400, "") end) + + assert {:error, "No account could be found for nobody.example"} = + Identity.resolve("nobody.example") + end + + test "reports a did the directory does not hold" do + stub(fn conn -> Plug.Conn.send_resp(conn, 404, "") end) + + assert {:error, "The directory returned HTTP 404 for did:plc:abc"} = + Identity.resolve("did:plc:abc") + end + + test "reports an account with no repository server to read from" do + stub(fn conn -> + Req.Test.json(conn, Map.put(did_doc("https://pds.example"), "service", [])) + end) + + assert {:error, "did:plc:abc has no atproto PDS to list repositories from"} = + Identity.resolve("did:plc:abc") + end + + test "reports an unreachable directory" do + stub(fn conn -> Req.Test.transport_error(conn, :econnrefused) end) + + assert {:error, "connection refused"} = Identity.resolve("did:plc:abc") + end +end diff --git a/test/git_sync_web/controllers/connection_controller_test.exs b/test/git_sync_web/controllers/connection_controller_test.exs index af7f82c..8d638f5 100644 --- a/test/git_sync_web/controllers/connection_controller_test.exs +++ b/test/git_sync_web/controllers/connection_controller_test.exs @@ -3,7 +3,6 @@ defmodule GitSyncWeb.ConnectionControllerTest do alias GitSync.Connections alias GitSync.Forge.Token - alias GitSync.KnotServer setup :configure_forgejo @@ -41,38 +40,78 @@ defmodule GitSyncWeb.ConnectionControllerTest do setup %{conn: conn} do Req.Test.stub(GitSync.Http, &Plug.Conn.send_resp(&1, 401, "")) - %{conn: conn, knot: KnotServer.serve()} + %{conn: conn} end - test "offers a form for the knot URL and nothing to paste", %{conn: conn} do + test "offers a form for the account and nothing to paste", %{conn: conn} do html = html_response(get(conn, ~p"/connections"), 200) assert html =~ "tangled-form" + assert html =~ "connection[handle]" refute html =~ "connection[ssh_key]" - refute html =~ "connection[host_key]" end - test "saving a knot scans it and shows the fingerprints", %{conn: conn, knot: knot} do - saved = post(conn, ~p"/connections/tangled", %{"connection" => knot_params()}) + test "saving an account resolves it and lists what it owns", %{conn: conn} do + stub_tangled() + + saved = post(conn, ~p"/connections/tangled", %{"connection" => %{"handle" => "oppi.li"}}) assert redirected_to(saved) == ~p"/connections" - assert Connections.tangled().base_url == "https://127.0.0.1" + assert Connections.tangled().did == "did:plc:abc" - assert html_response(get(conn, ~p"/connections"), 200) =~ - KnotServer.fingerprint(knot.public) + html = html_response(get(conn, ~p"/connections"), 200) + + assert html =~ "oppi.li" + assert html =~ "knot1.tangled.sh/oppi.li/git-sync" end - test "a knot that cannot be reached re-renders the page", %{conn: conn} do - KnotServer.refuse() + test "an account already saved can be resolved again from its own form", %{conn: conn} do + stub_tangled() + post(conn, ~p"/connections/tangled", %{"connection" => %{"handle" => "oppi.li"}}) + + html = html_response(get(conn, ~p"/connections"), 200) + form = html |> String.split(~s(id="tangled-form")) |> Enum.at(1) - conn = post(conn, ~p"/connections/tangled", %{"connection" => knot_params()}) + refute form =~ ~s(name="_method") - assert html_response(conn, 200) =~ "no host keys came back" + again = post(conn, ~p"/connections/tangled", %{"connection" => %{"handle" => "oppi.li"}}) + + assert redirected_to(again) == ~p"/connections" + end + + test "an account that cannot be resolved re-renders the page", %{conn: conn} do + conn = post(conn, ~p"/connections/tangled", %{"connection" => %{"handle" => "nobody"}}) + + assert html_response(conn, 200) =~ "No account could be found for nobody" refute Connections.tangled() end + test "reports why the repository list is unavailable", %{conn: conn} do + stub_tangled() + post(conn, ~p"/connections/tangled", %{"connection" => %{"handle" => "oppi.li"}}) + + Req.Test.stub(GitSync.Http, &Plug.Conn.send_resp(&1, 502, "")) + + assert html_response(get(conn, ~p"/connections"), 200) =~ + "Could not list Tangled repositories" + end + + test "shows the fingerprints of the knots it has pinned", %{conn: conn} do + stub_tangled() + post(conn, ~p"/connections/tangled", %{"connection" => %{"handle" => "oppi.li"}}) + + GitSync.Repo.insert!(%GitSync.Knot{ + connection_id: Connections.tangled().id, + host: "knot1.tangled.sh", + host_key: "knot1.tangled.sh ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIexample" + }) + + assert html_response(get(conn, ~p"/connections"), 200) =~ "knot1.tangled.sh" + end + test "generating a key shows the public half to copy", %{conn: conn} do - post(conn, ~p"/connections/tangled", %{"connection" => knot_params()}) + stub_tangled() + post(conn, ~p"/connections/tangled", %{"connection" => %{"handle" => "oppi.li"}}) generated = post(conn, ~p"/connections/tangled/key") @@ -84,13 +123,44 @@ defmodule GitSyncWeb.ConnectionControllerTest do refute html =~ "BEGIN OPENSSH PRIVATE KEY" end - test "there is nothing to generate a key for until a knot is saved", %{conn: conn} do + test "there is nothing to generate a key for until an account is saved", %{conn: conn} do conn = post(conn, ~p"/connections/tangled/key") assert redirected_to(conn) == ~p"/connections" - assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "No knot" + assert Phoenix.Flash.get(conn.assigns.flash, :error) =~ "No Tangled account" end - end - defp knot_params, do: %{"base_url" => "https://127.0.0.1"} + defp stub_tangled do + Req.Test.stub(GitSync.Http, fn conn -> + case conn.host do + "public.api.bsky.app" -> + Req.Test.json(conn, %{"did" => "did:plc:abc"}) + + "plc.directory" -> + Req.Test.json(conn, %{ + "alsoKnownAs" => ["at://oppi.li"], + "service" => [ + %{ + "type" => "AtprotoPersonalDataServer", + "serviceEndpoint" => "https://pds.example" + } + ] + }) + + "pds.example" -> + Req.Test.json(conn, %{ + "records" => [ + %{ + "uri" => "at://did:plc:abc/sh.tangled.repo/git-sync", + "value" => %{"knot" => "knot1.tangled.sh"} + } + ] + }) + + _forgejo -> + Plug.Conn.send_resp(conn, 401, "") + end + end) + end + end end -- 2.54.0