diff --git a/CHANGELOG.md b/CHANGELOG.md index e652cf4..155439b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.2.0 + +### Added + +- Validate full ATProto blob structure with stricter field checking + ## 1.1.0 ### Added diff --git a/gleam.toml b/gleam.toml index 479cb07..554021a 100644 --- a/gleam.toml +++ b/gleam.toml @@ -1,5 +1,5 @@ name = "honk" -version = "1.1.0" +version = "1.2.0" description = "ATProtocol lexicon validator for Gleam" internal_modules = ["honk/internal", "honk/internal/*"] licences = ["Apache-2.0"] diff --git a/manifest.toml b/manifest.toml index cc993fc..e8fe80a 100644 --- a/manifest.toml +++ b/manifest.toml @@ -6,8 +6,8 @@ packages = [ { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, - { name = "gleam_stdlib", version = "0.65.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "7C69C71D8C493AE11A5184828A77110EB05A7786EBF8B25B36A72F879C3EE107" }, - { name = "gleam_time", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "D560E672C7279C89908981E068DF07FD16D0C859DCA266F908B18F04DF0EB8E6" }, + { name = "gleam_stdlib", version = "0.67.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "6CE3E4189A8B8EC2F73AB61A2FBDE49F159D6C9C61C49E3B3082E439F260D3D0" }, + { name = "gleam_time", version = "1.6.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "0DF3834D20193F0A38D0EB21F0A78D48F2EC276C285969131B86DF8D4EF9E762" }, { name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" }, { name = "simplifile", version = "2.3.1", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "957E0E5B75927659F1D2A1B7B75D7B9BA96FAA8D0C53EA71C4AD9CD0C6B848F6" }, ] diff --git a/src/honk/validation/formats.gleam b/src/honk/validation/formats.gleam index 798412e..02ce62a 100644 --- a/src/honk/validation/formats.gleam +++ b/src/honk/validation/formats.gleam @@ -217,6 +217,15 @@ pub fn is_valid_cid(value: String) -> Bool { } } +/// Validates CID format with raw multicodec (0x55) for blobs +/// Base32 CIDv1 with raw multicodec starts with "bafkrei" +pub fn is_valid_raw_cid(value: String) -> Bool { + case is_valid_cid(value) { + False -> False + True -> string.starts_with(value, "bafkrei") + } +} + /// Validates BCP47 language tag pub fn is_valid_language_tag(value: String) -> Bool { // Lenient BCP47 validation (max 128 chars) diff --git a/src/honk/validation/primitive/blob.gleam b/src/honk/validation/primitive/blob.gleam index 60e0aa3..8e5164b 100644 --- a/src/honk/validation/primitive/blob.gleam +++ b/src/honk/validation/primitive/blob.gleam @@ -13,9 +13,12 @@ import honk/errors import honk/internal/constraints import honk/internal/json_helpers import honk/validation/context.{type ValidationContext} +import honk/validation/formats const allowed_fields = ["type", "accept", "maxSize", "description"] +const allowed_data_fields = ["$type", "ref", "mimeType", "size"] + /// Validates blob schema definition pub fn validate_schema( schema: Json, @@ -66,10 +69,37 @@ pub fn validate_data( Error(errors.data_validation(def_name <> ": expected blob object")) } True -> { - // Validate required mimeType field + // Validate no extra fields (strict mode per atproto implementation) + let keys = json_helpers.get_keys(data) + use _ <- result.try(validate_no_extra_fields(def_name, keys)) + + // Validate $type field must be "blob" + use _ <- result.try(case json_helpers.get_string(data, "$type") { + Some("blob") -> Ok(Nil) + Some(other) -> + Error(errors.data_validation( + def_name <> ": blob $type must be 'blob', got '" <> other <> "'", + )) + None -> + Error(errors.data_validation( + def_name <> ": blob missing required '$type' field", + )) + }) + + // Validate ref field with $link containing raw CID + use _ <- result.try(validate_ref_field(data, def_name)) + + // Validate required mimeType field (non-empty) use mime_type <- result.try( case json_helpers.get_string(data, "mimeType") { - Some(mt) -> Ok(mt) + Some(mt) -> + case string.is_empty(mt) { + True -> + Error(errors.data_validation( + def_name <> ": blob mimeType cannot be empty", + )) + False -> Ok(mt) + } None -> Error(errors.data_validation( def_name <> ": blob missing required 'mimeType' field", @@ -77,9 +107,16 @@ pub fn validate_data( }, ) - // Validate required size field + // Validate required size field (non-negative integer) use size <- result.try(case json_helpers.get_int(data, "size") { - Some(s) -> Ok(s) + Some(s) -> + case s >= 0 { + True -> Ok(s) + False -> + Error(errors.data_validation( + def_name <> ": blob size must be non-negative", + )) + } None -> Error(errors.data_validation( def_name <> ": blob missing or invalid 'size' field", @@ -114,6 +151,58 @@ pub fn validate_data( } } +/// Validates that blob data has no extra fields +fn validate_no_extra_fields( + def_name: String, + keys: List(String), +) -> Result(Nil, errors.ValidationError) { + let extra_keys = + list.filter(keys, fn(key) { !list.contains(allowed_data_fields, key) }) + case extra_keys { + [] -> Ok(Nil) + [first, ..] -> + Error(errors.data_validation( + def_name <> ": blob has unexpected field '" <> first <> "'", + )) + } +} + +/// Validates the ref field containing $link with raw CID +fn validate_ref_field( + data: Json, + def_name: String, +) -> Result(Nil, errors.ValidationError) { + case json_helpers.get_field(data, "ref") { + Some(ref_json) -> + case json_helpers.is_object(ref_json) { + False -> + Error(errors.data_validation( + def_name <> ": blob ref must be an object", + )) + True -> + case json_helpers.get_string(ref_json, "$link") { + Some(cid) -> + case formats.is_valid_raw_cid(cid) { + True -> Ok(Nil) + False -> + Error(errors.data_validation( + def_name + <> ": blob ref.$link must be a valid CID with raw multicodec (bafkrei prefix)", + )) + } + None -> + Error(errors.data_validation( + def_name <> ": blob ref must have $link field", + )) + } + } + None -> + Error(errors.data_validation( + def_name <> ": blob missing required 'ref' field", + )) + } +} + /// Validates accept field array fn validate_accept_field( def_name: String, diff --git a/test/blob_validator_test.gleam b/test/blob_validator_test.gleam index c10bf97..0ece81b 100644 --- a/test/blob_validator_test.gleam +++ b/test/blob_validator_test.gleam @@ -90,6 +90,18 @@ pub fn valid_blob_data_test() { let data = json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), #("mimeType", json.string("image/jpeg")), #("size", json.int(50_000)), ]) @@ -109,6 +121,18 @@ pub fn unaccepted_mime_type_test() { let data = json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), #("mimeType", json.string("video/mp4")), #("size", json.int(50_000)), ]) @@ -128,6 +152,18 @@ pub fn exceeds_max_size_test() { let data = json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), #("mimeType", json.string("image/jpeg")), #("size", json.int(50_000)), ]) @@ -141,7 +177,22 @@ pub fn exceeds_max_size_test() { pub fn missing_mime_type_test() { let schema = json.object([#("type", json.string("blob"))]) - let data = json.object([#("size", json.int(50_000))]) + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("size", json.int(50_000)), + ]) let assert Ok(ctx) = context.builder() |> context.build let result = blob.validate_data(data, schema, ctx) @@ -152,7 +203,290 @@ pub fn missing_mime_type_test() { pub fn missing_size_test() { let schema = json.object([#("type", json.string("blob"))]) - let data = json.object([#("mimeType", json.string("image/jpeg"))]) + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// ========== FULL BLOB STRUCTURE TESTS ========== + +// Test valid full blob structure +pub fn valid_full_blob_structure_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_ok +} + +// Test missing $type field +pub fn missing_type_field_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test wrong $type value +pub fn wrong_type_value_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("notblob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test missing ref field +pub fn missing_ref_field_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test ref without $link +pub fn ref_missing_link_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #("ref", json.object([#("cid", json.string("bafkrei..."))])), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test ref with invalid CID +pub fn ref_invalid_cid_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #("ref", json.object([#("$link", json.string("not-a-valid-cid"))])), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test ref with dag-cbor CID (should fail - blobs need raw multicodec) +pub fn ref_dag_cbor_cid_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test empty mimeType rejected +pub fn empty_mime_type_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("")), + #("size", json.int(50_000)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test size zero is allowed (per atproto implementation) +pub fn size_zero_allowed_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(0)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_ok +} + +// Test negative size rejected +pub fn negative_size_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(-100)), + ]) + + let assert Ok(ctx) = context.builder() |> context.build + let result = blob.validate_data(data, schema, ctx) + result |> should.be_error +} + +// Test extra fields are rejected (strict mode per atproto implementation) +pub fn extra_fields_rejected_test() { + let schema = json.object([#("type", json.string("blob"))]) + + let data = + json.object([ + #("$type", json.string("blob")), + #( + "ref", + json.object([ + #( + "$link", + json.string( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ), + ), + ]), + ), + #("mimeType", json.string("image/jpeg")), + #("size", json.int(50_000)), + #("extraField", json.string("not allowed")), + ]) let assert Ok(ctx) = context.builder() |> context.build let result = blob.validate_data(data, schema, ctx) diff --git a/test/format_validator_test.gleam b/test/format_validator_test.gleam index 5350f31..9d541b1 100644 --- a/test/format_validator_test.gleam +++ b/test/format_validator_test.gleam @@ -256,6 +256,36 @@ pub fn cid_empty_test() { formats.is_valid_cid("") |> should.be_false } +// ========== RAW CID TESTS ========== + +// Test valid raw CID (bafkrei prefix = CIDv1 + raw multicodec 0x55) +pub fn valid_raw_cid_test() { + formats.is_valid_raw_cid( + "bafkreigh2akiscaildcqabsyg3dfr6chu3fgpregiymsck7e7aqa4s52zy", + ) + |> should.be_true +} + +// Test dag-cbor CID rejected (bafyrei prefix = CIDv1 + dag-cbor multicodec 0x71) +pub fn invalid_raw_cid_dag_cbor_test() { + formats.is_valid_raw_cid( + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", + ) + |> should.be_false +} + +// Test CIDv0 rejected for raw CID +pub fn invalid_raw_cid_v0_test() { + formats.is_valid_raw_cid("QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR") + |> should.be_false +} + +// Test invalid CID rejected +pub fn invalid_raw_cid_garbage_test() { + formats.is_valid_raw_cid("not-a-cid") + |> should.be_false +} + // ========== LANGUAGE TESTS ========== pub fn language_valid_test() {