From a2c77512d606b5b9fdb663f2b9a2cc3c4d166676 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 13 Jun 2026 21:48:07 -0500 Subject: [PATCH] fix: validate blob refs and improve repo errors --- lib/tempest/lexicon/validator.ex | 11 ++++ lib/tempest/xrpc/repo.ex | 30 +++++++++ test/tempest/lexicon/validator_test.exs | 15 +++++ test/tempest_web/xrpc/records_test.exs | 86 +++++++++++++++++++++++++ 4 files changed, 142 insertions(+) diff --git a/lib/tempest/lexicon/validator.ex b/lib/tempest/lexicon/validator.ex index 56595cc..da26a43 100644 --- a/lib/tempest/lexicon/validator.ex +++ b/lib/tempest/lexicon/validator.ex @@ -128,6 +128,7 @@ defmodule Tempest.Lexicon.Validator do defp validate_value(value, %{"type" => "blob"} = schema, _document, path, _depth) do with :ok <- ensure_map(value, path), + :ok <- validate_blob_ref(value, path), :ok <- validate_optional_blob_size(value, schema, path), :ok <- validate_optional_blob_mime(value, schema, path) do :ok @@ -305,6 +306,16 @@ defmodule Tempest.Lexicon.Validator do defp validate_optional_blob_size(_value, _schema, _path), do: :ok + defp validate_blob_ref( + %{"$type" => "blob", "ref" => %{"$link" => cid}, "mimeType" => mime_type, "size" => size}, + path + ) + when is_binary(cid) and is_binary(mime_type) and is_integer(size) and size >= 0 do + validate_string_format(cid, "cid", join_path(path, "ref")) + end + + defp validate_blob_ref(_value, path), do: {:error, {:invalid_field, path}} + defp validate_optional_blob_mime(value, %{"accept" => accept}, path) when is_list(accept) do case Map.get(value, "mimeType") do nil -> diff --git a/lib/tempest/xrpc/repo.ex b/lib/tempest/xrpc/repo.ex index d81c3d8..90dbbdd 100644 --- a/lib/tempest/xrpc/repo.ex +++ b/lib/tempest/xrpc/repo.ex @@ -182,6 +182,9 @@ defmodule Tempest.Xrpc.Repo do defp repo_error(:missing_record_type), do: {:error, 400, "InvalidRequest", "record must include a $type field"} defp repo_error(:record_type_mismatch), do: {:error, 400, "InvalidRequest", "record $type must match collection"} defp repo_error(:unknown_lexicon), do: {:error, 400, "InvalidRequest", "record lexicon is unknown"} + defp repo_error(:invalid_record), do: {:error, 400, "InvalidRequest", "record is invalid"} + defp repo_error(:invalid_string), do: {:error, 400, "InvalidRequest", "record contains an invalid string"} + defp repo_error(:max_depth_exceeded), do: {:error, 400, "InvalidRequest", "record nesting is too deep"} defp repo_error(:missing_signing_key), do: {:error, 500, "InternalServerError", "account has no active signing key"} defp repo_error({:field_too_small, field}), do: {:error, 400, "InvalidRequest", "#{field} is too small"} defp repo_error({:field_too_large, field}), do: {:error, 400, "InvalidRequest", "#{field} is too large"} @@ -215,5 +218,32 @@ defmodule Tempest.Xrpc.Repo do defp repo_error({:field_too_short, field}), do: {:error, 400, "InvalidRequest", "#{field} is too short"} + defp repo_error({:invalid_schema, field}), + do: {:error, 400, "InvalidRequest", "#{field} schema is invalid"} + + defp repo_error({:unsupported_schema_type, field, type}), + do: {:error, 400, "InvalidRequest", "#{field} schema type #{type} is unsupported"} + + defp repo_error({:invalid_commit_event, _reason}), + do: {:error, 500, "InternalServerError", "repository commit event is invalid"} + + defp repo_error({:invalid_record_json, _reason}), + do: {:error, 500, "InternalServerError", "stored record JSON is invalid"} + + defp repo_error({:invalid_record_cid, _reason}), + do: {:error, 500, "InternalServerError", "stored record CID is invalid"} + + defp repo_error({:invalid_mst_node, _reason}), + do: {:error, 500, "InternalServerError", "repository tree is invalid"} + + defp repo_error({:invalid_commit, _reason}), + do: {:error, 500, "InternalServerError", "repository commit is invalid"} + + defp repo_error({:invalid_commit_cid, _reason}), + do: {:error, 500, "InternalServerError", "repository commit CID is invalid"} + + defp repo_error({:invalid_block_cid, _reason}), + do: {:error, 500, "InternalServerError", "repository block CID is invalid"} + defp repo_error(_reason), do: {:error, 500, "InternalServerError", "repository write failed"} end diff --git a/test/tempest/lexicon/validator_test.exs b/test/tempest/lexicon/validator_test.exs index 87d57d3..1432138 100644 --- a/test/tempest/lexicon/validator_test.exs +++ b/test/tempest/lexicon/validator_test.exs @@ -40,6 +40,21 @@ defmodule Tempest.Lexicon.ValidatorTest do Validator.validate_record("app.bsky.actor.profile", "self", record) end + test "rejects malformed blob references" do + record = %{ + "$type" => "app.bsky.actor.profile", + "avatar" => %{ + "$type" => "blob", + "cid" => Cid.for_raw("avatar") |> Cid.to_string(), + "mimeType" => "image/png", + "size" => 6 + } + } + + assert {:error, {:invalid_field, "app.bsky.actor.profile.avatar"}} = + Validator.validate_record("app.bsky.actor.profile", "self", record) + end + test "enforces record key types from the Lexicon definition" do record = %{"$type" => "app.bsky.actor.profile"} diff --git a/test/tempest_web/xrpc/records_test.exs b/test/tempest_web/xrpc/records_test.exs index cde8ade..2839ed0 100644 --- a/test/tempest_web/xrpc/records_test.exs +++ b/test/tempest_web/xrpc/records_test.exs @@ -607,6 +607,84 @@ defmodule TempestWeb.Xrpc.RecordsTest do assert File.exists?(Path.join([Tempest.Config.load!().data_dir, "blobs", path_did(account["did"]), cid])) end + test "putRecord updates Bluesky profile avatar and banner blobs", %{conn: conn} do + account = create_account!(conn, "records-profile-images.test", "records-profile-images@example.com") + created = create_profile!(conn, account, "Profile Images") + + avatar = upload_blob!(conn, account, png_bytes(), "image/png")["blob"] + banner = upload_blob!(conn, account, jpeg_bytes(), "image/jpeg")["blob"] + avatar_cid = avatar["ref"]["$link"] + banner_cid = banner["ref"]["$link"] + + assert blob_metadata(account["did"], avatar_cid).state == "temp" + assert blob_metadata(account["did"], banner_cid).state == "temp" + + updated = + conn + |> auth_json(account) + |> put_req_header("atproto-proxy", "did:web:api.bsky.app#bsky_appview") + |> put_req_header("atproto-accept-labelers", "did:plc:ar7c4by46qjdydhdevvrndac;redact") + |> post(~p"/xrpc/com.atproto.repo.putRecord", %{ + "repo" => account["did"], + "collection" => "app.bsky.actor.profile", + "rkey" => "self", + "swapRecord" => created["cid"], + "record" => %{ + "$type" => "app.bsky.actor.profile", + "avatar" => avatar, + "banner" => banner, + "createdAt" => "2026-06-13T20:20:43.434Z", + "description" => "tempest.desertthunder.dev", + "displayName" => "Tempest" + } + }) + |> json_response(200) + + assert updated["uri"] == "at://#{account["did"]}/app.bsky.actor.profile/self" + assert updated["cid"] != created["cid"] + assert updated["validationStatus"] == "valid" + assert blob_metadata(account["did"], avatar_cid).state == "public" + assert blob_metadata(account["did"], banner_cid).state == "public" + + profile = + conn + |> recycle() + |> get(~p"/xrpc/com.atproto.repo.getRecord", %{ + "repo" => account["did"], + "collection" => "app.bsky.actor.profile", + "rkey" => "self" + }) + |> json_response(200) + + assert profile["value"]["avatar"]["ref"]["$link"] == avatar_cid + assert profile["value"]["banner"]["ref"]["$link"] == banner_cid + end + + test "putRecord rejects malformed blob references with a client error", %{conn: conn} do + account = create_account!(conn, "records-profile-bad-blob.test", "records-profile-bad-blob@example.com") + created = create_profile!(conn, account, "Bad Blob") + uploaded = upload_blob!(conn, account, png_bytes(), "image/png")["blob"] + + rejected_conn = + conn + |> auth_json(account) + |> post(~p"/xrpc/com.atproto.repo.putRecord", %{ + "repo" => account["did"], + "collection" => "app.bsky.actor.profile", + "rkey" => "self", + "swapRecord" => created["cid"], + "record" => %{ + "$type" => "app.bsky.actor.profile", + "avatar" => Map.delete(uploaded, "ref"), + "displayName" => "Bad Blob" + } + }) + + response = json_response(rejected_conn, 400) + assert response["error"] == "InvalidRequest" + assert response["message"] =~ "avatar" + end + test "importRepo verifies CARs atomically and keeps post-import revisions monotonic", %{conn: conn} do account = create_account!(conn, "records-import.test", "records-import@example.com") profile = create_profile!(conn, account, "Imported") @@ -768,6 +846,14 @@ defmodule TempestWeb.Xrpc.RecordsTest do |> json_response(200) end + defp png_bytes do + <<0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A, "tempest-test-png">> + end + + defp jpeg_bytes do + <<0xFF, 0xD8, 0xFF, "tempest-test-jpeg">> + end + defp blob_record_params(repo, rkey, blob) do %{ "repo" => repo, -- 2.51.2