Gleam ATProto packages
20

Configure Feed

Select the types of activity you want to include in your feed.

core: nsid parser

Lewis: May this revision serve well! <lu5a@proton.me>

authored by

Lewis and committed by
Tangled
(Jul 15, 2026, 5:43 PM +0300) c377fbda b5e538c1

+701 -1673
+22
.tangled/workflows/core.yaml
··· 1 + when: 2 + - event: ["push", "pull-request"] 3 + branch: ["main"] 4 + paths: 5 + - core/** 6 + - identity/** 7 + 8 + engine: "microvm" 9 + 10 + registry: 11 + nixpkgs: github:nixos/nixpkgs/nixos-unstable 12 + 13 + dependencies: 14 + - gleam 15 + - beamMinimal29Packages.erlang 16 + - beamMinimal29Packages.rebar3 17 + 18 + steps: 19 + - name: "test" 20 + command: | 21 + cd core 22 + gleam test
+3
README.md
··· 18 18 ### [alicia_cbor](cbor/) 19 19 Library to encode/decode CBOR (RFC 8949) and subsets of it like DAG-CBOR. 20 20 21 + ### [alicia_core](core/) 22 + The core syntax/types of atproto. 23 + 21 24 # License 22 25 23 26 This project and its packages are all licensed under the Apache-2.0 License - see [LICENSE](LICENSE).
+4
core/.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+202
core/LICENSE
··· 1 + 2 + Apache License 3 + Version 2.0, January 2004 4 + http://www.apache.org/licenses/ 5 + 6 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 + 8 + 1. Definitions. 9 + 10 + "License" shall mean the terms and conditions for use, reproduction, 11 + and distribution as defined by Sections 1 through 9 of this document. 12 + 13 + "Licensor" shall mean the copyright owner or entity authorized by 14 + the copyright owner that is granting the License. 15 + 16 + "Legal Entity" shall mean the union of the acting entity and all 17 + other entities that control, are controlled by, or are under common 18 + control with that entity. For the purposes of this definition, 19 + "control" means (i) the power, direct or indirect, to cause the 20 + direction or management of such entity, whether by contract or 21 + otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 + outstanding shares, or (iii) beneficial ownership of such entity. 23 + 24 + "You" (or "Your") shall mean an individual or Legal Entity 25 + exercising permissions granted by this License. 26 + 27 + "Source" form shall mean the preferred form for making modifications, 28 + including but not limited to software source code, documentation 29 + source, and configuration files. 30 + 31 + "Object" form shall mean any form resulting from mechanical 32 + transformation or translation of a Source form, including but 33 + not limited to compiled object code, generated documentation, 34 + and conversions to other media types. 35 + 36 + "Work" shall mean the work of authorship, whether in Source or 37 + Object form, made available under the License, as indicated by a 38 + copyright notice that is included in or attached to the work 39 + (an example is provided in the Appendix below). 40 + 41 + "Derivative Works" shall mean any work, whether in Source or Object 42 + form, that is based on (or derived from) the Work and for which the 43 + editorial revisions, annotations, elaborations, or other modifications 44 + represent, as a whole, an original work of authorship. For the purposes 45 + of this License, Derivative Works shall not include works that remain 46 + separable from, or merely link (or bind by name) to the interfaces of, 47 + the Work and Derivative Works thereof. 48 + 49 + "Contribution" shall mean any work of authorship, including 50 + the original version of the Work and any modifications or additions 51 + to that Work or Derivative Works thereof, that is intentionally 52 + submitted to Licensor for inclusion in the Work by the copyright owner 53 + or by an individual or Legal Entity authorized to submit on behalf of 54 + the copyright owner. For the purposes of this definition, "submitted" 55 + means any form of electronic, verbal, or written communication sent 56 + to the Licensor or its representatives, including but not limited to 57 + communication on electronic mailing lists, source code control systems, 58 + and issue tracking systems that are managed by, or on behalf of, the 59 + Licensor for the purpose of discussing and improving the Work, but 60 + excluding communication that is conspicuously marked or otherwise 61 + designated in writing by the copyright owner as "Not a Contribution." 62 + 63 + "Contributor" shall mean Licensor and any individual or Legal Entity 64 + on behalf of whom a Contribution has been received by Licensor and 65 + subsequently incorporated within the Work. 66 + 67 + 2. Grant of Copyright License. Subject to the terms and conditions of 68 + this License, each Contributor hereby grants to You a perpetual, 69 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 + copyright license to reproduce, prepare Derivative Works of, 71 + publicly display, publicly perform, sublicense, and distribute the 72 + Work and such Derivative Works in Source or Object form. 73 + 74 + 3. Grant of Patent License. Subject to the terms and conditions of 75 + this License, each Contributor hereby grants to You a perpetual, 76 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 + (except as stated in this section) patent license to make, have made, 78 + use, offer to sell, sell, import, and otherwise transfer the Work, 79 + where such license applies only to those patent claims licensable 80 + by such Contributor that are necessarily infringed by their 81 + Contribution(s) alone or by combination of their Contribution(s) 82 + with the Work to which such Contribution(s) was submitted. If You 83 + institute patent litigation against any entity (including a 84 + cross-claim or counterclaim in a lawsuit) alleging that the Work 85 + or a Contribution incorporated within the Work constitutes direct 86 + or contributory patent infringement, then any patent licenses 87 + granted to You under this License for that Work shall terminate 88 + as of the date such litigation is filed. 89 + 90 + 4. Redistribution. You may reproduce and distribute copies of the 91 + Work or Derivative Works thereof in any medium, with or without 92 + modifications, and in Source or Object form, provided that You 93 + meet the following conditions: 94 + 95 + (a) You must give any other recipients of the Work or 96 + Derivative Works a copy of this License; and 97 + 98 + (b) You must cause any modified files to carry prominent notices 99 + stating that You changed the files; and 100 + 101 + (c) You must retain, in the Source form of any Derivative Works 102 + that You distribute, all copyright, patent, trademark, and 103 + attribution notices from the Source form of the Work, 104 + excluding those notices that do not pertain to any part of 105 + the Derivative Works; and 106 + 107 + (d) If the Work includes a "NOTICE" text file as part of its 108 + distribution, then any Derivative Works that You distribute must 109 + include a readable copy of the attribution notices contained 110 + within such NOTICE file, excluding those notices that do not 111 + pertain to any part of the Derivative Works, in at least one 112 + of the following places: within a NOTICE text file distributed 113 + as part of the Derivative Works; within the Source form or 114 + documentation, if provided along with the Derivative Works; or, 115 + within a display generated by the Derivative Works, if and 116 + wherever such third-party notices normally appear. The contents 117 + of the NOTICE file are for informational purposes only and 118 + do not modify the License. You may add Your own attribution 119 + notices within Derivative Works that You distribute, alongside 120 + or as an addendum to the NOTICE text from the Work, provided 121 + that such additional attribution notices cannot be construed 122 + as modifying the License. 123 + 124 + You may add Your own copyright statement to Your modifications and 125 + may provide additional or different license terms and conditions 126 + for use, reproduction, or distribution of Your modifications, or 127 + for any such Derivative Works as a whole, provided Your use, 128 + reproduction, and distribution of the Work otherwise complies with 129 + the conditions stated in this License. 130 + 131 + 5. Submission of Contributions. Unless You explicitly state otherwise, 132 + any Contribution intentionally submitted for inclusion in the Work 133 + by You to the Licensor shall be under the terms and conditions of 134 + this License, without any additional terms or conditions. 135 + Notwithstanding the above, nothing herein shall supersede or modify 136 + the terms of any separate license agreement you may have executed 137 + with Licensor regarding such Contributions. 138 + 139 + 6. Trademarks. This License does not grant permission to use the trade 140 + names, trademarks, service marks, or product names of the Licensor, 141 + except as required for reasonable and customary use in describing the 142 + origin of the Work and reproducing the content of the NOTICE file. 143 + 144 + 7. Disclaimer of Warranty. Unless required by applicable law or 145 + agreed to in writing, Licensor provides the Work (and each 146 + Contributor provides its Contributions) on an "AS IS" BASIS, 147 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 + implied, including, without limitation, any warranties or conditions 149 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 + PARTICULAR PURPOSE. You are solely responsible for determining the 151 + appropriateness of using or redistributing the Work and assume any 152 + risks associated with Your exercise of permissions under this License. 153 + 154 + 8. Limitation of Liability. In no event and under no legal theory, 155 + whether in tort (including negligence), contract, or otherwise, 156 + unless required by applicable law (such as deliberate and grossly 157 + negligent acts) or agreed to in writing, shall any Contributor be 158 + liable to You for damages, including any direct, indirect, special, 159 + incidental, or consequential damages of any character arising as a 160 + result of this License or out of the use or inability to use the 161 + Work (including but not limited to damages for loss of goodwill, 162 + work stoppage, computer failure or malfunction, or any and all 163 + other commercial damages or losses), even if such Contributor 164 + has been advised of the possibility of such damages. 165 + 166 + 9. Accepting Warranty or Additional Liability. While redistributing 167 + the Work or Derivative Works thereof, You may choose to offer, 168 + and charge a fee for, acceptance of support, warranty, indemnity, 169 + or other liability obligations and/or rights consistent with this 170 + License. However, in accepting such obligations, You may act only 171 + on Your own behalf and on Your sole responsibility, not on behalf 172 + of any other Contributor, and only if You agree to indemnify, 173 + defend, and hold each Contributor harmless for any liability 174 + incurred by, or claims asserted against, such Contributor by reason 175 + of your accepting any such warranty or additional liability. 176 + 177 + END OF TERMS AND CONDITIONS 178 + 179 + APPENDIX: How to apply the Apache License to your work. 180 + 181 + To apply the Apache License to your work, attach the following 182 + boilerplate notice, with the fields enclosed by brackets "[]" 183 + replaced with your own identifying information. (Don't include 184 + the brackets!) The text should be enclosed in the appropriate 185 + comment syntax for the file format. We also recommend that a 186 + file or class name and description of purpose be included on the 187 + same "printed page" as the copyright notice for easier 188 + identification within third-party archives. 189 + 190 + Copyright [yyyy] [name of copyright owner] 191 + 192 + Licensed under the Apache License, Version 2.0 (the "License"); 193 + you may not use this file except in compliance with the License. 194 + You may obtain a copy of the License at 195 + 196 + http://www.apache.org/licenses/LICENSE-2.0 197 + 198 + Unless required by applicable law or agreed to in writing, software 199 + distributed under the License is distributed on an "AS IS" BASIS, 200 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 + See the License for the specific language governing permissions and 202 + limitations under the License.
+27
core/README.md
··· 1 + # alicia_core 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/alicia_core)](https://hex.pm/packages/alicia_core) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/alicia_core/) 5 + 6 + The core syntax/types of atproto. 7 + 8 + ```sh 9 + gleam add alicia_core@1 10 + ``` 11 + ```gleam 12 + import alicia/core/nsid 13 + import gleam/io 14 + 15 + pub fn main() -> Nil { 16 + let assert Ok(parsed) = nsid.parse("app.bsky.feed.post") 17 + io.println(nsid.to_string(parsed)) 18 + } 19 + ``` 20 + 21 + Further documentation can be found at <https://hexdocs.pm/alicia_core>. 22 + 23 + ## Development 24 + 25 + ```sh 26 + gleam test # Run the tests 27 + ```
+20
core/gleam.toml
··· 1 + name = "alicia_core" 2 + version = "1.0.0" 3 + gleam = ">= 1.11.0" 4 + 5 + description = "atproto core syntax library" 6 + licences = ["Apache-2.0"] 7 + internal_modules = ["alicia/core/internal/*"] 8 + 9 + [repository] 10 + type = "tangled" 11 + user = "sheetr.app" 12 + repo = "alicia" 13 + path = "core" 14 + 15 + [dependencies] 16 + gleam_stdlib = ">= 1.0.0 and < 2.0.0" 17 + alicia_identity = { path = "../identity" } 18 + 19 + [dev_dependencies] 20 + gleeunit = ">= 1.0.0 and < 2.0.0"
+18
core/manifest.toml
··· 1 + # Do not manually edit this file, it is managed by Gleam. 2 + # 3 + # This file locks the dependency versions used, to make your build 4 + # deterministic and to prevent unexpected versions from being included 5 + # in your application. 6 + # 7 + # You should check this file into your source control repository. 8 + 9 + packages = [ 10 + { name = "alicia_identity", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], source = "local", path = "../identity" }, 11 + { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 12 + { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 13 + ] 14 + 15 + [requirements] 16 + alicia_identity = { path = "../identity" } 17 + gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 18 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+31
core/src/alicia/core/internal/ascii.gleam
··· 1 + pub fn every(bytes: BitArray, predicate: fn(Int) -> Bool) -> Bool { 2 + case bytes { 3 + <<>> -> True 4 + <<b, rest:bits>> -> 5 + case predicate(b) { 6 + True -> every(rest, predicate) 7 + False -> False 8 + } 9 + _ -> False 10 + } 11 + } 12 + 13 + pub fn is_ascii(b: Int) -> Bool { 14 + b <= 0x7f 15 + } 16 + 17 + pub fn is_digit(b: Int) -> Bool { 18 + b >= 0x30 && b <= 0x39 19 + } 20 + 21 + pub fn is_alnum(b: Int) -> Bool { 22 + is_alpha(b) || is_digit(b) 23 + } 24 + 25 + fn is_alpha(b: Int) -> Bool { 26 + case b { 27 + _ if b >= 0x61 && b <= 0x7a -> True 28 + _ if b >= 0x41 && b <= 0x5a -> True 29 + _ -> False 30 + } 31 + }
+161
core/src/alicia/core/nsid.gleam
··· 1 + //// # NSID (Namespaced Identifier) 2 + //// 3 + //// Official Documentation: 4 + //// [atproto.com/specs/nsid](https://atproto.com/specs/nsid) 5 + //// 6 + //// NSIDs identify Lexicon schemas: record types, XRPC endpoints, & more. 7 + //// An NSID is a domain authority followed by one final name-segment, 8 + //// where the authority is a hostname in reverse domain-name order. 9 + //// In `com.atproto.sync.getRecord` the authority is `com.atproto.sync` 10 + //// and the name is `getRecord`. As the reader can see, 11 + //// it is very Java-flavored. 12 + //// 13 + //// `parse` normalizes the authority to lowercase. 14 + //// ...A name-segment gets to keep its casing though! 15 + //// The 253-byte hostname limit does not apply to the authority. 16 + //// Only the 317-byte total limit and the 63-byte per segment apply, 17 + //// and the interop vectors seem to accept a 283-byte authority. 18 + //// 19 + //// Interop vectors: 20 + //// [github.com/bluesky-social/atproto-interop-tests](https://github.com/bluesky-social/atproto-interop-tests) 21 + 22 + import alicia/core/internal/ascii 23 + import alicia/identity/domain 24 + import gleam/bit_array 25 + import gleam/bool 26 + import gleam/list 27 + import gleam/result 28 + import gleam/string 29 + 30 + /// A parsed, syntactically valid NSID. 31 + pub opaque type Nsid { 32 + Nsid(authority: domain.Domain, name: Name) 33 + } 34 + 35 + /// The final segment of an NSID. 36 + /// ASCII letters and digits only, no hyphens, 37 + /// no leading digit, max 63 bytes. 38 + /// Case-sensitive and never normalized. 39 + pub opaque type Name { 40 + Name(String) 41 + } 42 + 43 + /// Every way in which the `parse` function can reject an input. 44 + pub type ParseError { 45 + /// Beyond the 317-byte limit. 46 + TooLong 47 + /// NSID needs at least three segments, 48 + /// two for the authority and one for the name. 49 + TooFewSegments 50 + /// Label inside the authority failed with the given `domain.LabelError`. 51 + InvalidAuthority(reason: domain.LabelError) 52 + /// Name segment is missing entirely. 53 + EmptyName 54 + /// Name over 63 bytes. 55 + NameTooLong 56 + /// Name starts with a digit. 57 + NameLeadsWithDigit 58 + /// Name byte which isn't an ASCII letter or digit. 59 + InvalidNameCharacter 60 + } 61 + 62 + /// The overall NSID byte limit from the spec. 63 + pub const max_byte_length = 317 64 + 65 + const max_name_length = 63 66 + 67 + /// Constructing an NSID. 68 + /// Lowercases the authority but keeps the name-segment as-is. 69 + /// `domain.parse_segments` checks the authority segments without the 70 + /// 253-byte hostname limit. 71 + /// 72 + /// For example: 73 + /// - `parse("cafe.oyster.beepBoop")` -> `Ok(...)` 74 + /// - `parse("cafe.oyster")` -> `Error(TooFewSegments)` 75 + /// - `parse("cafe.oyster.3")` -> `Error(NameLeadsWithDigit)` 76 + pub fn parse(raw: String) -> Result(Nsid, ParseError) { 77 + use <- bool.guard(string.byte_size(raw) > max_byte_length, Error(TooLong)) 78 + case list.reverse(string.split(raw, ".")) { 79 + [final, second, third, ..rest] -> { 80 + use name <- result.try(parse_name(final)) 81 + use authority <- result.try(parse_authority(second, third, rest)) 82 + Ok(Nsid(authority:, name:)) 83 + } 84 + _ -> Error(TooFewSegments) 85 + } 86 + } 87 + 88 + fn parse_name(raw: String) -> Result(Name, ParseError) { 89 + let bytes = bit_array.from_string(raw) 90 + use <- bool.guard( 91 + !ascii.every(bytes, ascii.is_alnum), 92 + Error(InvalidNameCharacter), 93 + ) 94 + use <- bool.guard( 95 + bit_array.byte_size(bytes) > max_name_length, 96 + Error(NameTooLong), 97 + ) 98 + case bytes { 99 + <<first, _:bits>> -> 100 + case ascii.is_digit(first) { 101 + True -> Error(NameLeadsWithDigit) 102 + False -> Ok(Name(raw)) 103 + } 104 + _ -> Error(EmptyName) 105 + } 106 + } 107 + 108 + fn parse_authority( 109 + first: String, 110 + next: String, 111 + rest: List(String), 112 + ) -> Result(domain.Domain, ParseError) { 113 + let ascii_only = 114 + list.all([first, next, ..rest], fn(segment) { 115 + ascii.every(bit_array.from_string(segment), ascii.is_ascii) 116 + }) 117 + use <- bool.guard( 118 + !ascii_only, 119 + Error(InvalidAuthority(domain.InvalidCharacter)), 120 + ) 121 + domain.parse_segments( 122 + string.lowercase(first), 123 + string.lowercase(next), 124 + list.map(rest, string.lowercase), 125 + ) 126 + |> result.map_error(InvalidAuthority) 127 + } 128 + 129 + /// The inverse of `parse`. 130 + /// An uppercase authority will render lowercased. 131 + pub fn to_string(nsid: Nsid) -> String { 132 + authority_to_string(nsid.authority) <> "." <> name_to_string(nsid.name) 133 + } 134 + 135 + /// Accessor for domain authority, in hostname order. 136 + pub fn authority(nsid: Nsid) -> domain.Domain { 137 + nsid.authority 138 + } 139 + 140 + /// Accessor for the name-segment. 141 + pub fn name(nsid: Nsid) -> Name { 142 + nsid.name 143 + } 144 + 145 + /// Renders a `domain.Domain` in reverse domain-name order. 146 + /// Java-ify. 147 + /// `oyster.cafe` becomes `cafe.oyster`. 148 + pub fn authority_to_string(authority: domain.Domain) -> String { 149 + let labels = 150 + list.map(domain.leading_labels(authority), domain.label_to_string) 151 + string.join( 152 + [domain.tld_to_string(domain.tld(authority)), ..list.reverse(labels)], 153 + ".", 154 + ) 155 + } 156 + 157 + /// Unwraps a `Name` to its text. 158 + pub fn name_to_string(name: Name) -> String { 159 + let Name(value) = name 160 + value 161 + }
+70
core/test/alicia_core_test.gleam
··· 1 + import alicia/core/nsid 2 + import alicia/identity/domain 3 + import gleam/list 4 + import gleam/string 5 + import gleeunit 6 + 7 + pub fn main() -> Nil { 8 + gleeunit.main() 9 + } 10 + 11 + pub fn nsid_parse_test() { 12 + let assert Ok(parsed) = nsid.parse("com.atproto.sync.getRecord") 13 + assert nsid.name_to_string(nsid.name(parsed)) == "getRecord" 14 + assert nsid.authority_to_string(nsid.authority(parsed)) == "com.atproto.sync" 15 + assert domain.to_string(nsid.authority(parsed)) == "sync.atproto.com" 16 + assert nsid.to_string(parsed) == "com.atproto.sync.getRecord" 17 + let assert Ok(dev) = nsid.parse("test.oyster.menu") 18 + assert domain.tld(nsid.authority(dev)) == domain.Test 19 + ["app.bsky.feed.post", "cafe.oyster.menu", "pet.nel.walk"] 20 + |> list.each(fn(input) { 21 + let assert Ok(parsed) = nsid.parse(input) as input 22 + assert nsid.to_string(parsed) == input as input 23 + }) 24 + let assert Ok(upper) = nsid.parse("SKIN.Lesbian.beepBoop") 25 + assert nsid.to_string(upper) == "skin.lesbian.beepBoop" 26 + assert nsid.parse("SKIN.Lesbian.beepBoop") 27 + == nsid.parse("skin.lesbian.beepBoop") 28 + assert nsid.parse("skin.lesbian.beepBoop") 29 + != nsid.parse("skin.lesbian.beepboop") 30 + } 31 + 32 + pub fn nsid_rejects_test() { 33 + [ 34 + #("com.\u{212A}elvin.thing", nsid.InvalidAuthority(domain.InvalidCharacter)), 35 + #("com..thing", nsid.InvalidAuthority(domain.EmptyLabel)), 36 + #(".pet.nel.walk", nsid.InvalidAuthority(domain.EmptyLabel)), 37 + #("8.nel.walk", nsid.InvalidAuthority(domain.TldLeadsWithDigit)), 38 + #("com.-nel.walk", nsid.InvalidAuthority(domain.HyphenAtLabelEdge)), 39 + #("com.nel-.walk", nsid.InvalidAuthority(domain.HyphenAtLabelEdge)), 40 + #( 41 + "com." <> string.repeat("a", 64) <> ".walk", 42 + nsid.InvalidAuthority(domain.LabelTooLong), 43 + ), 44 + #("skin.lesbian", nsid.TooFewSegments), 45 + #("", nsid.TooFewSegments), 46 + #("skin.lesbian.", nsid.EmptyName), 47 + #("skin.lesbian.3", nsid.NameLeadsWithDigit), 48 + #("skin.lesbian.beep-bar", nsid.InvalidNameCharacter), 49 + #("skin.lesbian.b💩o", nsid.InvalidNameCharacter), 50 + #("skin.lesbian." <> string.repeat("a", 64), nsid.NameTooLong), 51 + ] 52 + |> list.each(fn(pair) { 53 + let #(input, expected) = pair 54 + assert nsid.parse(input) == Error(expected) as input 55 + }) 56 + } 57 + 58 + pub fn nsid_length_limits_test() { 59 + let label = string.repeat("a", 63) 60 + let longest_authority = 61 + [label, label, label, label, string.repeat("b", 59)] 62 + |> string.join(".") 63 + let longest = longest_authority <> ".c" 64 + assert string.byte_size(longest_authority) > domain.max_byte_length 65 + assert string.byte_size(longest) == 317 66 + let assert Ok(parsed) = nsid.parse(longest) as longest 67 + assert nsid.to_string(parsed) == longest 68 + assert nsid.parse("b" <> longest) == Error(nsid.TooLong) 69 + assert nsid.parse(string.repeat("a", 1_000_000)) == Error(nsid.TooLong) 70 + }
+68
core/test/nsid_interop_test.gleam
··· 1 + import alicia/core/nsid 2 + import gleam/list 3 + import gleam/string 4 + 5 + pub fn nsid_interop_valid_test() { 6 + [ 7 + "com." <> string.repeat("o", 63) <> ".boop", 8 + "skin.lesbian." <> string.repeat("o", 63), 9 + "com" <> string.repeat(".middle", 40) <> ".boop", 10 + "skin.lesbian.beepBoop", 11 + "skin.lesbian.beepBoopV2", 12 + "net.users.nel.ping", 13 + "a.b.c", 14 + "m.xn--ewisowski-pub.pl", 15 + "one.two.three", 16 + "one.two.three.four-and.FiVe", 17 + "one.2.three", 18 + "a-0.b-1.c", 19 + "a0.b1.cc", 20 + "cn.8.lex.stuff", 21 + "test.12345.record", 22 + "a01.thing.record", 23 + "a.0.c", 24 + "xn--fiqs8s.xn--fiqa61au8b7zsevnm8ak20mc4a87e.record.two", 25 + "a0.b1.c3", 26 + "skin.lesbian.m00", 27 + "onion.bqmkv4w3cwcpi6og6o3nfmflrzgc3rxp4l2rk77k7ybfvawfr5fuujid.lex.deleteThing", 28 + "org.2chan.lex.getThing", 29 + ] 30 + |> list.each(fn(input) { 31 + let assert Ok(parsed) = nsid.parse(input) as input 32 + assert nsid.to_string(parsed) == input as input 33 + }) 34 + } 35 + 36 + pub fn nsid_interop_invalid_test() { 37 + [ 38 + "com." <> string.repeat("o", 64) <> ".boop", 39 + "skin.lesbian." <> string.repeat("o", 64), 40 + "com" <> string.repeat(".middle", 50) <> ".boop", 41 + "skin.lesbian.boop.*", 42 + "skin.lesbian.boop.blah*", 43 + "skin.lesbian.boop.*blah", 44 + "skin.le💩bian.bloop", 45 + "a-0.b-1.c-3", 46 + "a-0.b-1.c-o", 47 + "1.0.0.127.record", 48 + "0two.example.boop", 49 + "example.com", 50 + "skin.lesbian.", 51 + "a.", 52 + ".one.two.three", 53 + "one.two.three ", 54 + "one.two..three", 55 + "one .two.three", 56 + " one.two.three", 57 + "com.atproto.feed.p@st", 58 + "com.atproto.feed.p_st", 59 + "com.atproto.feed.p*st", 60 + "com.atproto.feed.po#t", 61 + "com.atproto.feed.p!ot", 62 + "skin.lesbian..boop", 63 + "skin.lesbian.boopBar.2", 64 + ] 65 + |> list.each(fn(input) { 66 + let assert Error(_) = nsid.parse(input) as input 67 + }) 68 + }
-1633
git-patch-736088460.patch
··· 1 - diff --git a/git-patch-3690543680.patch b/git-patch-3690543680.patch 2 - deleted file mode 100644 3 - index 4682d9a..0000000 4 - --- a/git-patch-3690543680.patch 5 - +++ /dev/null 6 - @@ -1,682 +0,0 @@ 7 - -diff --git a/identity/gleam.toml b/identity/gleam.toml 8 - -index 5c6395c..f450f8a 100644 9 - ---- a/identity/gleam.toml 10 - -+++ b/identity/gleam.toml 11 - -@@ -12,9 +12,6 @@ path = "identity" 12 - - 13 - - [dependencies] 14 - - gleam_stdlib = ">= 1.0.0 and < 2.0.0" 15 - --gleam_regexp = ">= 1.1.1 and < 2.0.0" 16 - - 17 - - [dev_dependencies] 18 - - gleeunit = ">= 1.0.0 and < 2.0.0" 19 - --pprint = ">= 1.0.6 and < 2.0.0" 20 - --birdie = ">= 2.0.1 and < 3.0.0" 21 - -diff --git a/identity/manifest.toml b/identity/manifest.toml 22 - -index 34648b5..2f0ae47 100644 23 - ---- a/identity/manifest.toml 24 - -+++ b/identity/manifest.toml 25 - -@@ -2,35 +2,10 @@ 26 - - # You typically do not need to edit this file 27 - - 28 - - packages = [ 29 - -- { name = "argv", version = "1.1.0", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "3277D100448BDB4A29B6D58C0F36F631CBC349E8BDD09766C6309DF202831140" }, 30 - -- { name = "birdie", version = "2.0.1", build_tools = ["gleam"], requirements = ["argv", "edit_distance", "envoy", "filepath", "glance", "gleam_community_ansi", "gleam_json", "gleam_stdlib", "global_value", "justin", "rank", "simplifile", "term_size", "tom", "trie_again"], otp_app = "birdie", source = "hex", outer_checksum = "1A7923D57DFB63885F1EFBCD5ECE3E4A805FB680F9DFFDB5E85D64A05DD3A563" }, 31 - -- { name = "edit_distance", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "edit_distance", source = "hex", outer_checksum = "DE588BC3483ED6DBA717211B59F3178891A5FC6F1C00D415AE8C4233EAFB94B1" }, 32 - -- { name = "envoy", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "9C6FBB6BFA02A52798BEEC5977A738CAD6E4A057F4B67FD0C8061AD2502C191A" }, 33 - -- { name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" }, 34 - -- { name = "glam", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glam", source = "hex", outer_checksum = "48A043A12091F93D911C316DD8F7FFD03CD9FF6F50848FC56E359D3C168DF0FA" }, 35 - -- { name = "glance", version = "6.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "9037EBBAD2A220CD46DADEDCDF9DFAC7406FD2959535334473E60C32F170622A" }, 36 - -- { name = "gleam_community_ansi", version = "1.5.0", build_tools = ["gleam"], requirements = ["gleam_community_colour", "gleam_regexp", "gleam_stdlib"], otp_app = "gleam_community_ansi", source = "hex", outer_checksum = "B5AA433AF84313E23FDF90CCFF752B9380FE9FFCE02B2949D49B7AACCC77B16D" }, 37 - -- { name = "gleam_community_colour", version = "2.0.4", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib"], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "6DB4665555D7D2B27F0EA32EF47E8BEBC4303821765F9C73D483F38EE24894F0" }, 38 - -- { name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" }, 39 - -- { name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" }, 40 - - { name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" }, 41 - -- { name = "gleam_time", version = "1.8.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "533D8723774D61AD4998324F5DD1DABDCDBFABAFB9E87CB5D03C6955448FC97D" }, 42 - - { name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" }, 43 - -- { name = "glexer", version = "2.4.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "5AB2401BF251699746B3551EF699ECC1D94FE2897C15041F181614B089125CA0" }, 44 - -- { name = "global_value", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "global_value", source = "hex", outer_checksum = "23F74C91A7B819C43ABCCBF49DAD5BB8799D81F2A3736BA9A534BD47F309FF4F" }, 45 - -- { name = "justin", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "justin", source = "hex", outer_checksum = "8B1C62269E8607D0A0ED698B7903984CE0BF7B6B3C0DEA3C6C8302B39402837B" }, 46 - -- { name = "pprint", version = "1.0.6", build_tools = ["gleam"], requirements = ["glam", "gleam_stdlib"], otp_app = "pprint", source = "hex", outer_checksum = "4E9B34AE03B2E81D60F230B9BAF1792BE1AC37AFB5564B8DEBEE56BAEC866B7D" }, 47 - -- { name = "rank", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "rank", source = "hex", outer_checksum = "B846D54D1512FB83A3994A07A8C7915FE74335A99BCE1EDC7943EA31ED710DFD" }, 48 - -- { name = "simplifile", version = "2.5.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "6C72DCCDF25C38A5931740B30E823969F33106831FD1637719B5EDBCA30027A4" }, 49 - -- { name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" }, 50 - -- { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, 51 - -- { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, 52 - -- { name = "trie_again", version = "1.1.4", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "trie_again", source = "hex", outer_checksum = "E3BD66B4E126EF567EA8C4944EAB216413392ADF6C16C36047AF79EE5EF13466" }, 53 - - ] 54 - - 55 - - [requirements] 56 - --birdie = { version = ">= 2.0.1 and < 3.0.0" } 57 - --gleam_regexp = { version = ">= 1.1.1 and < 2.0.0" } 58 - - gleam_stdlib = { version = ">= 1.0.0 and < 2.0.0" } 59 - - gleeunit = { version = ">= 1.0.0 and < 2.0.0" } 60 - --pprint = { version = ">= 1.0.6 and < 2.0.0" } 61 - -diff --git a/identity/src/alicia/identity/did.gleam b/identity/src/alicia/identity/did.gleam 62 - -index 6f05be1..b5a1ee9 100644 63 - ---- a/identity/src/alicia/identity/did.gleam 64 - -+++ b/identity/src/alicia/identity/did.gleam 65 - -@@ -18,45 +18,351 @@ 66 - - //// using atproto and path-based DIDs are not supported at all. The top-level 67 - - //// domain restrictions applied to handles are also applied to `did:web` DIDs. 68 - - 69 - --import gleam/option 70 - --import gleam/regexp 71 - --import gleam/result 72 - -+import gleam/bit_array 73 - -+import gleam/bool 74 - -+import gleam/int 75 - -+import gleam/list 76 - -+import gleam/option.{type Option, None, Some} 77 - -+import gleam/string 78 - - 79 - --const did_regex: String = "^did:([a-z]+):([a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])$" 80 - -+/// A parsed & valid DID, with variants as per those that atproto allows. 81 - -+/// `parse` will return a valid DID or error out. Since `did:plc` and `did:web` are 82 - -+/// the only variants that atproto supports right now, they get their own variants, 83 - -+/// and everything else goes in the `Other` hole for now. 84 - -+/// 85 - -+/// Read more about DIDs: [atproto.com/specs/did](https://atproto.com/specs/did) 86 - -+pub type Did { 87 - -+ /// A `did:plc` identifier means one that comes form the "Public Ledger of Credentials", 88 - -+ /// which is a hosted/centralized self-authenticating method from Bluesky the company. 89 - -+ /// `did:plc`'s are exactly 24 characters at time of writing. 90 - -+ /// 91 - -+ /// Read more about PLC dir here: 92 - -+ /// [github.com/did-method-plc/did-method-plc](https://github.com/did-method-plc/did-method-plc) 93 - -+ Plc(id: PlcId) 94 - -+ /// A `did:web` identifier is one that is hosted (and tied to) a web domain, over HTTPS 95 - -+ /// & DNS. Atproto only allows for hostname-level `did:web`s, which means no path-based ones. 96 - -+ /// Ports are only allowed for localhost (for testing). 97 - -+ /// 98 - -+ /// Read more about `did:web`s: 99 - -+ /// [w3c-ccg.github.io/did-method-web](https://w3c-ccg.github.io/did-method-web) and 100 - -+ /// [atproto.com/specs/did](https://atproto.com/specs/did) 101 - -+ Web(host: WebHost) 102 - -+ /// This will parse any technically-valid DID that isn't part of atproto's current 103 - -+ /// blessed set for identification, such as `did:key` for example. 104 - -+ /// 105 - -+ /// Read more about the world of `did`s: 106 - -+ /// [www.w3.org/TR/did-1.0/#did-syntax](https://www.w3.org/TR/did-1.0/#did-syntax) 107 - -+ Other(method: MethodName, identifier: Identifier) 108 - -+ // did:lumina ofc 109 - -+} 110 - -+ 111 - -+/// The 24-character, RFC 4648 base32 body that comes after `did:plc`. 112 - -+pub opaque type PlcId { 113 - -+ PlcId(String) 114 - -+} 115 - -+ 116 - -+/// The host of a `did:web`. 117 - -+pub opaque type WebHost { 118 - -+ /// A valid domain on the interwebs! 119 - -+ Domain(String) 120 - -+ /// Localhost testing host with optional port. 121 - -+ Localhost(Option(Port)) 122 - -+} 123 - -+ 124 - -+/// A TCP port, used for a localhost `did:web`. 125 - -+/// It's included in the DID as the value after a web-encoded colon `%3A`. 126 - -+pub opaque type Port { 127 - -+ Port(Int) 128 - -+} 129 - -+ 130 - -+/// The `<method>` in `did:<method>:...`. 131 - -+/// Lowercase letters only. 132 - -+pub opaque type MethodName { 133 - -+ MethodName(String) 134 - -+} 135 - -+ 136 - -+/// The method-specific ID of an `Other` DID, saved as-is. 137 - -+pub opaque type Identifier { 138 - -+ Identifier(String) 139 - -+} 140 - -+ 141 - -+/// Every way in which the `parse` function can reject an input. 142 - -+pub type ParseError { 143 - -+ /// Input does not start with lowercase `did:`. 144 - -+ NotADid 145 - -+ /// Method segment is not lowercase alpha. 146 - -+ InvalidMethod 147 - -+ /// `did:plc:` prefix, but actual body is not 24 base32 chars. 148 - -+ InvalidPlcIdentifier 149 - -+ /// Bad domain, or a port on a host that's not localhost. 150 - -+ InvalidWebHost 151 - -+ /// `Other` DID with an empty/illegal ID. 152 - -+ InvalidIdentifier 153 - -+ /// Beyond the 2048-byte limit. 154 - -+ TooLong 155 - -+ // Should we put a TooShort? 156 - -+} 157 - -+ 158 - -+const max_byte_length = 2048 159 - -+ 160 - -+const plc_identifier_length = 24 161 - -+ 162 - -+const max_label_length = 63 163 - -+ 164 - -+const min_port = 1 165 - -+ 166 - -+const max_port = 65_535 167 - -+ 168 - -+/// DID construction! 169 - -+/// Validates atproto DID syntax depending on the prefix. 170 - -+/// Doesn't check for live existence/reachability, only purely parsing. 171 - -+/// 172 - -+/// For example: 173 - -+/// - `parse("did:web:oyster.cafe")` -> `Ok(Web(...))`; 174 - -+/// - `parse("did:plc:3fwecdnvtcscjnrx2p4n7alz")` -> `Ok(Plc(...))`; 175 - -+/// - `parse("notadid")` -> `Error(NotADid)`. 176 - -+/// 177 - -+/// Read more about DIDs: [atproto.com/specs/did](https://atproto.com/specs/did) 178 - -+pub fn parse(raw: String) -> Result(Did, ParseError) { 179 - -+ use <- bool.guard(string.byte_size(raw) > max_byte_length, Error(TooLong)) 180 - -+ case raw { 181 - -+ "did:plc:" <> rest -> parse_plc(rest) 182 - -+ "did:web:" <> rest -> parse_web(rest) 183 - -+ "did:" <> rest -> parse_other(rest) 184 - -+ _ -> Error(NotADid) 185 - -+ } 186 - -+} 187 - -+ 188 - -+fn parse_plc(rest: String) -> Result(Did, ParseError) { 189 - -+ case is_plc_identifier(rest) { 190 - -+ True -> Ok(Plc(PlcId(rest))) 191 - -+ False -> Error(InvalidPlcIdentifier) 192 - -+ } 193 - -+} 194 - -+ 195 - -+fn parse_web(rest: String) -> Result(Did, ParseError) { 196 - -+ case rest { 197 - -+ "localhost" -> Ok(Web(Localhost(None))) 198 - -+ "localhost%3A" <> port -> localhost_with_port(port) 199 - -+ "localhost%3a" <> port -> localhost_with_port(port) 200 - -+ _ -> 201 - -+ case is_domain(rest) { 202 - -+ True -> Ok(Web(Domain(rest))) 203 - -+ False -> Error(InvalidWebHost) 204 - -+ } 205 - -+ } 206 - -+} 207 - -+ 208 - -+fn localhost_with_port(raw: String) -> Result(Did, ParseError) { 209 - -+ case parse_port(raw) { 210 - -+ Ok(port) -> Ok(Web(Localhost(Some(port)))) 211 - -+ Error(Nil) -> Error(InvalidWebHost) 212 - -+ } 213 - -+} 214 - -+ 215 - -+fn parse_other(rest: String) -> Result(Did, ParseError) { 216 - -+ case string.split_once(rest, ":") { 217 - -+ Error(Nil) -> 218 - -+ case is_method(rest) { 219 - -+ True -> Error(InvalidIdentifier) 220 - -+ False -> Error(InvalidMethod) 221 - -+ } 222 - -+ Ok(#(method, identifier)) -> 223 - -+ case is_method(method), is_identifier(identifier) { 224 - -+ False, _ -> Error(InvalidMethod) 225 - -+ _, False -> Error(InvalidIdentifier) 226 - -+ True, True -> Ok(Other(MethodName(method), Identifier(identifier))) 227 - -+ } 228 - -+ } 229 - -+} 230 - -+ 231 - -+/// The inverse of `parse`: rendering a DID back to a string. 232 - -+/// A lowercase `%3a` port separator gets turned into `%3A`. 233 - -+pub fn to_string(did: Did) -> String { 234 - -+ case did { 235 - -+ Plc(PlcId(id)) -> "did:plc:" <> id 236 - -+ Web(host) -> "did:web:" <> web_host_to_string(host) 237 - -+ Other(MethodName(method), Identifier(identifier)) -> 238 - -+ "did:" <> method <> ":" <> identifier 239 - -+ } 240 - -+} 241 - -+ 242 - -+/// Unwraps a `PlcId` to its 24-char base32-string. 243 - -+pub fn plc_id_to_string(id: PlcId) -> String { 244 - -+ let PlcId(value) = id 245 - -+ value 246 - -+} 247 - -+ 248 - -+/// Renders a `WebHost` as it appears in the DID. 249 - -+/// Localhost's port get re-encoded as `%3A`. 250 - -+pub fn web_host_to_string(host: WebHost) -> String { 251 - -+ case host { 252 - -+ Domain(value) -> value 253 - -+ Localhost(None) -> "localhost" 254 - -+ Localhost(Some(Port(value))) -> "localhost%3A" <> int.to_string(value) 255 - -+ } 256 - -+} 257 - -+ 258 - -+/// True when the host is localhost, False for real domains. 259 - -+pub fn web_host_is_localhost(host: WebHost) -> Bool { 260 - -+ case host { 261 - -+ Localhost(_) -> True 262 - -+ Domain(_) -> False 263 - -+ } 264 - -+} 265 - - 266 - --pub type DidError { 267 - -- RegexCompileError(regexp.CompileError) 268 - -- InvalidDid 269 - -+/// The port for a localhost host, or None. 270 - -+/// Actual FQDNs should never contain a port. 271 - -+pub fn web_host_port(host: WebHost) -> Option(Port) { 272 - -+ case host { 273 - -+ Localhost(port) -> port 274 - -+ Domain(_) -> None 275 - -+ } 276 - -+} 277 - -+ 278 - -+/// Unwraps a `Port` to its `Int` value. 279 - -+pub fn port_to_int(port: Port) -> Int { 280 - -+ let Port(value) = port 281 - -+ value 282 - - } 283 - - 284 - --pub opaque type Did { 285 - -- Did(did: String, method: String, identifier: String) 286 - -+/// Unwraps a `MethodName` to its lowercase string. 287 - -+pub fn method_name_to_string(method: MethodName) -> String { 288 - -+ let MethodName(value) = method 289 - -+ value 290 - - } 291 - - 292 - --pub fn to_string(did did: Did) -> String { 293 - -- did.did 294 - -+/// Unwraps an `Identifier` to its raw string. 295 - -+pub fn identifier_to_string(identifier: Identifier) -> String { 296 - -+ let Identifier(value) = identifier 297 - -+ value 298 - - } 299 - - 300 - --pub fn method(did did: Did) -> String { 301 - -- did.method 302 - -+fn is_plc_identifier(id: String) -> Bool { 303 - -+ let bytes = bit_array.from_string(id) 304 - -+ bit_array.byte_size(bytes) == plc_identifier_length 305 - -+ && every_byte(bytes, is_base32_char) 306 - - } 307 - - 308 - --pub fn identifier(did did: Did) -> String { 309 - -- did.identifier 310 - -+fn is_method(method: String) -> Bool { 311 - -+ let bytes = bit_array.from_string(method) 312 - -+ bit_array.byte_size(bytes) > 0 && every_byte(bytes, is_lower_alpha) 313 - - } 314 - - 315 - --pub fn parse(did did_string: String) -> Result(Did, DidError) { 316 - -- use regex <- result.try( 317 - -- regexp.from_string(did_regex) 318 - -- |> result.map_error(RegexCompileError), 319 - -+fn is_identifier(identifier: String) -> Bool { 320 - -+ scan( 321 - -+ bit_array.from_string(identifier), 322 - -+ is_identifier_char, 323 - -+ is_identifier_final_char, 324 - - ) 325 - -- case regexp.scan(regex, did_string) { 326 - -- [ 327 - -- regexp.Match( 328 - -- content: did, 329 - -- submatches: [option.Some(method), option.Some(identifier)], 330 - -- ), 331 - -- ] -> Ok(Did(did:, method:, identifier:)) 332 - -- _ -> Error(InvalidDid) 333 - -+} 334 - -+ 335 - -+fn parse_port(raw: String) -> Result(Port, Nil) { 336 - -+ case raw { 337 - -+ "0" <> _ -> Error(Nil) 338 - -+ _ -> { 339 - -+ let bytes = bit_array.from_string(raw) 340 - -+ case bit_array.byte_size(bytes) > 0 && every_byte(bytes, is_digit) { 341 - -+ False -> Error(Nil) 342 - -+ True -> 343 - -+ case int.parse(raw) { 344 - -+ Ok(value) if value >= min_port && value <= max_port -> 345 - -+ Ok(Port(value)) 346 - -+ _ -> Error(Nil) 347 - -+ } 348 - -+ } 349 - -+ } 350 - -+ } 351 - -+} 352 - -+ 353 - -+fn is_domain(host: String) -> Bool { 354 - -+ case list.reverse(string.split(host, ".")) { 355 - -+ [tld, ..labels] -> labels != [] && is_tld(tld) && list.all(labels, is_label) 356 - -+ [] -> False 357 - -+ } 358 - -+} 359 - -+ 360 - -+fn is_label(label: String) -> Bool { 361 - -+ is_labelish(label, is_lower_alnum) 362 - -+} 363 - -+ 364 - -+fn is_tld(label: String) -> Bool { 365 - -+ is_labelish(label, is_lower_alpha) 366 - -+} 367 - -+ 368 - -+fn is_labelish(label: String, first: fn(Int) -> Bool) -> Bool { 369 - -+ let bytes = bit_array.from_string(label) 370 - -+ let size = bit_array.byte_size(bytes) 371 - -+ size >= 1 && size <= max_label_length && label_shape(bytes, first) 372 - -+} 373 - -+ 374 - -+fn label_shape(bytes: BitArray, first: fn(Int) -> Bool) -> Bool { 375 - -+ case bytes { 376 - -+ <<b>> -> first(b) 377 - -+ <<b, rest:bits>> -> 378 - -+ first(b) && scan(rest, is_lower_alnum_or_hyphen, is_lower_alnum) 379 - -+ _ -> False 380 - -+ } 381 - -+} 382 - -+ 383 - -+fn every_byte(bytes: BitArray, predicate: fn(Int) -> Bool) -> Bool { 384 - -+ case bytes { 385 - -+ <<>> -> True 386 - -+ <<b, rest:bits>> -> predicate(b) && every_byte(rest, predicate) 387 - -+ _ -> False 388 - -+ } 389 - -+} 390 - -+ 391 - -+fn scan(bytes: BitArray, mid: fn(Int) -> Bool, last: fn(Int) -> Bool) -> Bool { 392 - -+ case bytes { 393 - -+ <<b>> -> last(b) 394 - -+ <<b, rest:bits>> -> mid(b) && scan(rest, mid, last) 395 - -+ _ -> False 396 - -+ } 397 - -+} 398 - -+ 399 - -+fn is_lower_alpha(b: Int) -> Bool { 400 - -+ b >= 0x61 && b <= 0x7a 401 - -+} 402 - -+ 403 - -+fn is_alpha(b: Int) -> Bool { 404 - -+ case b { 405 - -+ _ if b >= 0x61 && b <= 0x7a -> True 406 - -+ _ if b >= 0x41 && b <= 0x5a -> True 407 - -+ _ -> False 408 - -+ } 409 - -+} 410 - -+ 411 - -+fn is_digit(b: Int) -> Bool { 412 - -+ b >= 0x30 && b <= 0x39 413 - -+} 414 - -+ 415 - -+fn is_base32_char(b: Int) -> Bool { 416 - -+ is_lower_alpha(b) || { b >= 0x32 && b <= 0x37 } 417 - -+} 418 - -+ 419 - -+fn is_alnum(b: Int) -> Bool { 420 - -+ is_alpha(b) || is_digit(b) 421 - -+} 422 - -+ 423 - -+fn is_lower_alnum(b: Int) -> Bool { 424 - -+ is_lower_alpha(b) || is_digit(b) 425 - -+} 426 - -+ 427 - -+fn is_lower_alnum_or_hyphen(b: Int) -> Bool { 428 - -+ b == 0x2d || is_lower_alnum(b) 429 - -+} 430 - -+ 431 - -+fn is_identifier_char(b: Int) -> Bool { 432 - -+ case b { 433 - -+ 0x2e | 0x5f | 0x3a | 0x25 | 0x2d -> True 434 - -+ _ -> is_alnum(b) 435 - -+ } 436 - -+} 437 - -+ 438 - -+fn is_identifier_final_char(b: Int) -> Bool { 439 - -+ case b { 440 - -+ 0x2e | 0x5f | 0x2d -> True 441 - -+ _ -> is_alnum(b) 442 - - } 443 - - } 444 - -diff --git a/identity/test/alicia_identity_test.gleam b/identity/test/alicia_identity_test.gleam 445 - -index 7caa799..e7e7640 100644 446 - ---- a/identity/test/alicia_identity_test.gleam 447 - -+++ b/identity/test/alicia_identity_test.gleam 448 - -@@ -1,32 +1,153 @@ 449 - - import alicia/identity/did 450 - --import birdie 451 - -+import gleam/list 452 - -+import gleam/option.{None, Some} 453 - -+import gleam/string 454 - - import gleeunit 455 - --import pprint 456 - - 457 - - pub fn main() -> Nil { 458 - - gleeunit.main() 459 - - } 460 - - 461 - --pub fn invalid_did_test() { 462 - -- did.parse("dfkg hdkvdfgnkghd") 463 - -- |> pprint.format 464 - -- |> birdie.snap("Invalid DID Random Input") 465 - -+pub fn parse_plc_test() { 466 - -+ let assert Ok(did.Plc(id)) = did.parse("did:plc:ew3qowq4bsc67qohlxcbmsdb") 467 - -+ assert did.plc_id_to_string(id) == "ew3qowq4bsc67qohlxcbmsdb" 468 - - } 469 - - 470 - --pub fn valid_did_web_test() { 471 - -- did.parse("did:web:sheetr.app") 472 - -- |> pprint.format 473 - -- |> birdie.snap("Valid DID web") 474 - -+pub fn parse_web_test() { 475 - -+ let assert Ok(did.Web(host)) = did.parse("did:web:sheetr.app") 476 - -+ assert did.web_host_to_string(host) == "sheetr.app" 477 - - } 478 - - 479 - --pub fn valid_did_plc_test() { 480 - -- did.parse("did:plc:ew3qowq4bsc67qohlxcbmsdb") 481 - -- |> pprint.format 482 - -- |> birdie.snap("Valid DID plc") 483 - -+pub fn web_localhost_is_allowed_test() { 484 - -+ let assert Ok(did.Web(host)) = did.parse("did:web:localhost") 485 - -+ assert did.web_host_is_localhost(host) == True 486 - -+ assert did.web_host_port(host) == None 487 - -+ assert did.web_host_to_string(host) == "localhost" 488 - - } 489 - - 490 - --pub fn valid_did_unknown_method_test() { 491 - -- did.parse("did:newthing:289342934928347") 492 - -- |> pprint.format 493 - -- |> birdie.snap("Valid DID with unknown method") 494 - -+pub fn web_localhost_with_port_is_allowed_test() { 495 - -+ let assert Ok(did.Web(host)) = did.parse("did:web:localhost%3A3000") 496 - -+ assert did.web_host_is_localhost(host) == True 497 - -+ let assert Some(port) = did.web_host_port(host) 498 - -+ assert did.port_to_int(port) == 3000 499 - -+} 500 - -+ 501 - -+pub fn web_localhost_lowercase_hex_port_is_parsed_and_canonicalized_test() { 502 - -+ let assert Ok(parsed) = did.parse("did:web:localhost%3a8080") 503 - -+ let assert did.Web(host) = parsed 504 - -+ let assert Some(port) = did.web_host_port(host) 505 - -+ assert did.port_to_int(port) == 8080 506 - -+ assert did.to_string(parsed) == "did:web:localhost%3A8080" 507 - -+} 508 - -+ 509 - -+pub fn web_domain_is_not_localhost_test() { 510 - -+ let assert Ok(did.Web(host)) = did.parse("did:web:localhost.example.com") 511 - -+ assert did.web_host_is_localhost(host) == False 512 - -+ assert did.web_host_port(host) == None 513 - -+} 514 - -+ 515 - -+pub fn web_single_char_tld_is_allowed_test() { 516 - -+ let assert Ok(did.Web(_)) = did.parse("did:web:example.t") 517 - -+} 518 - -+ 519 - -+pub fn web_reserved_tld_passes_syntax_test() { 520 - -+ let assert Ok(did.Web(_)) = did.parse("did:web:laptop.local") 521 - -+ let assert Ok(did.Web(_)) = did.parse("did:web:blah.arpa") 522 - -+} 523 - -+ 524 - -+pub fn parse_unsupported_method_test() { 525 - -+ let assert Ok(did.Other(method, identifier)) = 526 - -+ did.parse("did:newthing:289342934928347") 527 - -+ assert did.method_name_to_string(method) == "newthing" 528 - -+ assert did.identifier_to_string(identifier) == "289342934928347" 529 - -+} 530 - -+ 531 - -+pub fn spec_valid_unsupported_examples_test() { 532 - -+ let assert Ok(did.Other(_, _)) = did.parse("did:method:val:two") 533 - -+ let assert Ok(did.Other(_, _)) = did.parse("did:m:v") 534 - -+ let assert Ok(did.Other(_, _)) = did.parse("did:method::::val") 535 - -+ let assert Ok(did.Other(_, _)) = did.parse("did:method:-:_:.") 536 - -+ let assert Ok(did.Other(_, _)) = 537 - -+ did.parse("did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N") 538 - -+} 539 - -+ 540 - -+pub fn web_rejects_test() { 541 - -+ [ 542 - -+ "did:web:example", 543 - -+ "did:web:localhost%3A007", 544 - -+ "did:web:localhost%3A00", 545 - -+ "did:web:localhost%3A0", 546 - -+ "did:web:localhost%3A99999", 547 - -+ "did:web:localhost%3A", 548 - -+ "did:web:Sheetr.app", 549 - -+ "did:web:sheetr.App", 550 - -+ "did:web:EXAMPLE.COM", 551 - -+ "did:web:sheetr.app%3A3000", 552 - -+ "did:web:sheetr.123", 553 - -+ "did:web:cn.8", 554 - -+ "did:web:127.0.0.1", 555 - -+ "did:web:" <> string.repeat("a", 64) <> ".app", 556 - -+ "did:web:sheetr.app.", 557 - -+ "did:web:sheetr.app:user:nel", 558 - -+ ] 559 - -+ |> list.each(fn(input) { 560 - -+ assert did.parse(input) == Error(did.InvalidWebHost) as input 561 - -+ }) 562 - -+} 563 - -+ 564 - -+pub fn rejects_invalid_dids_test() { 565 - -+ [ 566 - -+ #("did:plc:foo", did.InvalidPlcIdentifier), 567 - -+ #("did:plc:1w3qowq4bsc67qohlxcbmsdb", did.InvalidPlcIdentifier), 568 - -+ #("did:METHOD:val", did.InvalidMethod), 569 - -+ #("did:m123:val", did.InvalidMethod), 570 - -+ #("DID:method:val", did.NotADid), 571 - -+ #("did:method:", did.InvalidIdentifier), 572 - -+ #("did:method:val/two", did.InvalidIdentifier), 573 - -+ #("did:method:val?two", did.InvalidIdentifier), 574 - -+ #("did:method:val#two", did.InvalidIdentifier), 575 - -+ #("dfkg hdkvdfgnkghd", did.NotADid), 576 - -+ #("did:newthing", did.InvalidIdentifier), 577 - -+ #("did:NEWTHING", did.InvalidMethod), 578 - -+ ] 579 - -+ |> list.each(fn(pair) { 580 - -+ let #(input, expected) = pair 581 - -+ assert did.parse(input) == Error(expected) as input 582 - -+ }) 583 - -+} 584 - -+ 585 - -+pub fn too_long_is_rejected_test() { 586 - -+ let long = "did:plc:" <> string.repeat("a", 2048) 587 - -+ assert did.parse(long) == Error(did.TooLong) 588 - -+} 589 - -+ 590 - -+pub fn round_trip_test() { 591 - -+ let inputs = [ 592 - -+ "did:plc:ew3qowq4bsc67qohlxcbmsdb", 593 - -+ "did:web:sheetr.app", 594 - -+ "did:web:localhost", 595 - -+ "did:web:localhost%3A3000", 596 - -+ "did:newthing:289342934928347", 597 - -+ ] 598 - -+ list.each(inputs, fn(input) { 599 - -+ let assert Ok(parsed) = did.parse(input) 600 - -+ assert did.to_string(parsed) == input as input 601 - -+ }) 602 - -+} 603 - -+ 604 - -+pub fn method_dispatch_is_exhaustive_test() { 605 - -+ let assert Ok(plc) = did.parse("did:plc:ew3qowq4bsc67qohlxcbmsdb") 606 - -+ let assert Ok(web) = did.parse("did:web:sheetr.app") 607 - -+ let assert Ok(other) = did.parse("did:newthing:289342934928347") 608 - -+ assert describe(plc) == "plc" 609 - -+ assert describe(web) == "web" 610 - -+ assert describe(other) == "newthing" 611 - -+} 612 - -+ 613 - -+fn describe(value: did.Did) -> String { 614 - -+ case value { 615 - -+ did.Plc(_) -> "plc" 616 - -+ did.Web(_) -> "web" 617 - -+ did.Other(method, _) -> did.method_name_to_string(method) 618 - -+ } 619 - - } 620 - -diff --git a/identity/test/birdie_snapshots/invalid_did_random_input.accepted b/identity/test/birdie_snapshots/invalid_did_random_input.accepted 621 - -deleted file mode 100644 622 - -index c41d153..0000000 623 - ---- a/identity/test/birdie_snapshots/invalid_did_random_input.accepted 624 - -+++ /dev/null 625 - -@@ -1,7 +0,0 @@ 626 - ----- 627 - --version: 2.0.1 628 - --title: Invalid DID Random Input 629 - --file: ./test/alicia_identity_test.gleam 630 - --test_name: invalid_did_test 631 - ----- 632 - --Error(InvalidDid) 633 - -diff --git a/identity/test/birdie_snapshots/valid_did_plc.accepted b/identity/test/birdie_snapshots/valid_did_plc.accepted 634 - -deleted file mode 100644 635 - -index 7a22705..0000000 636 - ---- a/identity/test/birdie_snapshots/valid_did_plc.accepted 637 - -+++ /dev/null 638 - -@@ -1,11 +0,0 @@ 639 - ----- 640 - --version: 2.0.1 641 - --title: Valid DID plc 642 - --file: ./test/alicia_identity_test.gleam 643 - --test_name: valid_did_plc_test 644 - ----- 645 - --Ok(Did( 646 - -- "did:plc:ew3qowq4bsc67qohlxcbmsdb", 647 - -- "plc", 648 - -- "ew3qowq4bsc67qohlxcbmsdb", 649 - --)) 650 - -diff --git a/identity/test/birdie_snapshots/valid_did_web.accepted b/identity/test/birdie_snapshots/valid_did_web.accepted 651 - -deleted file mode 100644 652 - -index b182b92..0000000 653 - ---- a/identity/test/birdie_snapshots/valid_did_web.accepted 654 - -+++ /dev/null 655 - -@@ -1,11 +0,0 @@ 656 - ----- 657 - --version: 2.0.1 658 - --title: Valid DID web 659 - --file: ./test/alicia_identity_test.gleam 660 - --test_name: valid_did_web_test 661 - ----- 662 - --Ok(Did( 663 - -- "did:web:sheetr.app", 664 - -- "web", 665 - -- "sheetr.app", 666 - --)) 667 - -diff --git a/identity/test/birdie_snapshots/valid_did_with_unknown_method.accepted b/identity/test/birdie_snapshots/valid_did_with_unknown_method.accepted 668 - -deleted file mode 100644 669 - -index ae1b534..0000000 670 - ---- a/identity/test/birdie_snapshots/valid_did_with_unknown_method.accepted 671 - -+++ /dev/null 672 - -@@ -1,11 +0,0 @@ 673 - ----- 674 - --version: 2.0.1 675 - --title: Valid DID with unknown method 676 - --file: ./test/alicia_identity_test.gleam 677 - --test_name: valid_did_unknown_method_test 678 - ----- 679 - --Ok(Did( 680 - -- "did:newthing:289342934928347", 681 - -- "newthing", 682 - -- "289342934928347", 683 - --)) 684 - - 685 - - 686 - - 687 - - 688 - - 689 - diff --git a/identity/gleam.toml b/identity/gleam.toml 690 - index f450f8a..f774b8f 100644 691 - --- a/identity/gleam.toml 692 - +++ b/identity/gleam.toml 693 - @@ -3,6 +3,7 @@ version = "1.0.0" 694 - 695 - description = "atproto identity library" 696 - licences = ["Apache-2.0"] 697 - +internal_modules = ["alicia/identity/internal/*"] 698 - 699 - [repository] 700 - type = "tangled" 701 - diff --git a/identity/src/alicia/identity/did.gleam b/identity/src/alicia/identity/did.gleam 702 - index 36ebfcb..b9e1bc7 100644 703 - --- a/identity/src/alicia/identity/did.gleam 704 - +++ b/identity/src/alicia/identity/did.gleam 705 - @@ -18,11 +18,13 @@ 706 - //// using atproto and path-based DIDs are not supported at all. The top-level 707 - //// domain restrictions applied to handles are also applied to `did:web` DIDs. 708 - 709 - +import alicia/identity/domain 710 - +import alicia/identity/internal/ascii 711 - import gleam/bit_array 712 - import gleam/bool 713 - import gleam/int 714 - -import gleam/list 715 - import gleam/option.{type Option, None, Some} 716 - +import gleam/result 717 - import gleam/string 718 - 719 - /// A parsed & valid DID, with variants as per those that atproto allows. 720 - @@ -64,7 +66,7 @@ pub opaque type PlcId { 721 - /// The host of a `did:web`. 722 - pub opaque type WebHost { 723 - /// A valid domain on the interwebs! 724 - - Domain(String) 725 - + Domain(domain.Domain) 726 - /// Localhost testing host with optional port. 727 - Localhost(Option(Port)) 728 - } 729 - @@ -95,7 +97,9 @@ pub type ParseError { 730 - /// `did:plc:` prefix, but actual body is not 24 base32 chars. 731 - InvalidPlcIdentifier 732 - /// Bad domain, or a port on a host that's not localhost. 733 - - InvalidWebHost 734 - + InvalidWebHost(reason: domain.ParseError) 735 - + /// localhost port that failed to parse. 736 - + InvalidPort 737 - /// `Other` DID with an empty/illegal ID. 738 - InvalidIdentifier 739 - /// Beyond the 2048-byte limit. 740 - @@ -107,10 +111,6 @@ const max_byte_length = 2048 741 - 742 - const plc_identifier_length = 24 743 - 744 - -const max_label_length = 63 745 - - 746 - -const min_port = 1 747 - - 748 - const max_port = 65_535 749 - 750 - /// DID construction! 751 - @@ -145,19 +145,20 @@ fn parse_web(rest: String) -> Result(Did, ParseError) { 752 - "localhost" -> Ok(Web(Localhost(None))) 753 - "localhost%3A" <> port -> localhost_with_port(port) 754 - "localhost%3a" <> port -> localhost_with_port(port) 755 - - _ -> 756 - - case is_domain(rest) { 757 - - True -> Ok(Web(Domain(rest))) 758 - - False -> Error(InvalidWebHost) 759 - - } 760 - + _ -> parse_domain(rest) 761 - } 762 - } 763 - 764 - +fn parse_domain(raw: String) -> Result(Did, ParseError) { 765 - + domain.parse(raw) 766 - + |> result.map(fn(parsed) { Web(Domain(parsed)) }) 767 - + |> result.map_error(InvalidWebHost) 768 - +} 769 - + 770 - fn localhost_with_port(raw: String) -> Result(Did, ParseError) { 771 - - case parse_port(raw) { 772 - - Ok(port) -> Ok(Web(Localhost(Some(port)))) 773 - - Error(Nil) -> Error(InvalidWebHost) 774 - - } 775 - + parse_port(raw) 776 - + |> result.map(fn(port) { Web(Localhost(Some(port))) }) 777 - + |> result.replace_error(InvalidPort) 778 - } 779 - 780 - fn parse_other(rest: String) -> Result(Did, ParseError) { 781 - @@ -197,7 +198,7 @@ pub fn plc_id_to_string(id: PlcId) -> String { 782 - /// Localhost's port get re-encoded as `%3A`. 783 - pub fn web_host_to_string(host: WebHost) -> String { 784 - case host { 785 - - Domain(value) -> value 786 - + Domain(value) -> domain.to_string(value) 787 - Localhost(None) -> "localhost" 788 - Localhost(Some(Port(value))) -> "localhost%3A" <> int.to_string(value) 789 - } 790 - @@ -211,12 +212,21 @@ pub fn web_host_is_localhost(host: WebHost) -> Bool { 791 - } 792 - } 793 - 794 - -/// The port for a localhost host, or None. 795 - +/// The port for a localhost host. 796 - /// Actual FQDNs should never contain a port. 797 - -pub fn web_host_port(host: WebHost) -> Option(Port) { 798 - +pub fn web_host_port(host: WebHost) -> Result(Port, Nil) { 799 - case host { 800 - - Localhost(port) -> port 801 - - Domain(_) -> None 802 - + Localhost(port) -> option.to_result(port, Nil) 803 - + Domain(_) -> Error(Nil) 804 - + } 805 - +} 806 - + 807 - +/// The inverse of `web_host_port` for domains. 808 - +/// `Ok` for real domains, and `Error` for localhost! :p 809 - +pub fn web_host_domain(host: WebHost) -> Result(domain.Domain, Nil) { 810 - + case host { 811 - + Domain(value) -> Ok(value) 812 - + Localhost(_) -> Error(Nil) 813 - } 814 - } 815 - 816 - @@ -241,16 +251,16 @@ pub fn identifier_to_string(identifier: Identifier) -> String { 817 - fn is_plc_identifier(id: String) -> Bool { 818 - let bytes = bit_array.from_string(id) 819 - bit_array.byte_size(bytes) == plc_identifier_length 820 - - && every_byte(bytes, is_base32_char) 821 - + && ascii.every(bytes, is_base32_char) 822 - } 823 - 824 - fn is_method(method: String) -> Bool { 825 - let bytes = bit_array.from_string(method) 826 - - bit_array.byte_size(bytes) > 0 && every_byte(bytes, is_lower_alpha) 827 - + bit_array.byte_size(bytes) > 0 && ascii.every(bytes, ascii.is_lower_alpha) 828 - } 829 - 830 - fn is_identifier(identifier: String) -> Bool { 831 - - scan( 832 - + ascii.scan( 833 - bit_array.from_string(identifier), 834 - is_identifier_char, 835 - is_identifier_final_char, 836 - @@ -262,12 +272,13 @@ fn parse_port(raw: String) -> Result(Port, Nil) { 837 - "0" <> _ -> Error(Nil) 838 - _ -> { 839 - let bytes = bit_array.from_string(raw) 840 - - case bit_array.byte_size(bytes) > 0 && every_byte(bytes, is_digit) { 841 - + case 842 - + bit_array.byte_size(bytes) > 0 && ascii.every(bytes, ascii.is_digit) 843 - + { 844 - False -> Error(Nil) 845 - True -> 846 - case int.parse(raw) { 847 - - Ok(value) if value >= min_port && value <= max_port -> 848 - - Ok(Port(value)) 849 - + Ok(value) if value <= max_port -> Ok(Port(value)) 850 - _ -> Error(Nil) 851 - } 852 - } 853 - @@ -275,94 +286,20 @@ fn parse_port(raw: String) -> Result(Port, Nil) { 854 - } 855 - } 856 - 857 - -fn is_domain(host: String) -> Bool { 858 - - case list.reverse(string.split(host, ".")) { 859 - - [tld, ..labels] -> labels != [] && is_tld(tld) && list.all(labels, is_label) 860 - - [] -> False 861 - - } 862 - -} 863 - - 864 - -fn is_label(label: String) -> Bool { 865 - - is_labelish(label, is_lower_alnum) 866 - -} 867 - - 868 - -fn is_tld(label: String) -> Bool { 869 - - is_labelish(label, is_lower_alpha) 870 - -} 871 - - 872 - -fn is_labelish(label: String, first: fn(Int) -> Bool) -> Bool { 873 - - let bytes = bit_array.from_string(label) 874 - - let size = bit_array.byte_size(bytes) 875 - - size >= 1 && size <= max_label_length && label_shape(bytes, first) 876 - -} 877 - - 878 - -fn label_shape(bytes: BitArray, first: fn(Int) -> Bool) -> Bool { 879 - - case bytes { 880 - - <<b>> -> first(b) 881 - - <<b, rest:bits>> -> 882 - - first(b) && scan(rest, is_lower_alnum_or_hyphen, is_lower_alnum) 883 - - _ -> False 884 - - } 885 - -} 886 - - 887 - -fn every_byte(bytes: BitArray, predicate: fn(Int) -> Bool) -> Bool { 888 - - case bytes { 889 - - <<>> -> True 890 - - <<b, rest:bits>> -> predicate(b) && every_byte(rest, predicate) 891 - - _ -> False 892 - - } 893 - -} 894 - - 895 - -fn scan(bytes: BitArray, mid: fn(Int) -> Bool, last: fn(Int) -> Bool) -> Bool { 896 - - case bytes { 897 - - <<b>> -> last(b) 898 - - <<b, rest:bits>> -> mid(b) && scan(rest, mid, last) 899 - - _ -> False 900 - - } 901 - -} 902 - - 903 - -fn is_lower_alpha(b: Int) -> Bool { 904 - - b >= 0x61 && b <= 0x7a 905 - -} 906 - - 907 - -fn is_alpha(b: Int) -> Bool { 908 - - case b { 909 - - _ if b >= 0x61 && b <= 0x7a -> True 910 - - _ if b >= 0x41 && b <= 0x5a -> True 911 - - _ -> False 912 - - } 913 - -} 914 - - 915 - -fn is_digit(b: Int) -> Bool { 916 - - b >= 0x30 && b <= 0x39 917 - -} 918 - - 919 - fn is_base32_char(b: Int) -> Bool { 920 - - is_lower_alpha(b) || { b >= 0x32 && b <= 0x37 } 921 - -} 922 - - 923 - -fn is_alnum(b: Int) -> Bool { 924 - - is_alpha(b) || is_digit(b) 925 - -} 926 - - 927 - -fn is_lower_alnum(b: Int) -> Bool { 928 - - is_lower_alpha(b) || is_digit(b) 929 - -} 930 - - 931 - -fn is_lower_alnum_or_hyphen(b: Int) -> Bool { 932 - - b == 0x2d || is_lower_alnum(b) 933 - + ascii.is_lower_alpha(b) || { b >= 0x32 && b <= 0x37 } 934 - } 935 - 936 - fn is_identifier_char(b: Int) -> Bool { 937 - case b { 938 - 0x2e | 0x5f | 0x3a | 0x25 | 0x2d -> True 939 - - _ -> is_alnum(b) 940 - + _ -> ascii.is_alnum(b) 941 - } 942 - } 943 - 944 - fn is_identifier_final_char(b: Int) -> Bool { 945 - case b { 946 - 0x2e | 0x5f | 0x2d -> True 947 - - _ -> is_alnum(b) 948 - + _ -> ascii.is_alnum(b) 949 - } 950 - } 951 - diff --git a/identity/src/alicia/identity/domain.gleam b/identity/src/alicia/identity/domain.gleam 952 - new file mode 100644 953 - index 0000000..e6ef3dd 954 - --- /dev/null 955 - +++ b/identity/src/alicia/identity/domain.gleam 956 - @@ -0,0 +1,221 @@ 957 - +//// # Domain 958 - +//// 959 - +//// Parsing DNS hostnames. 960 - +//// Used in both handles and `did:web` hosts. 961 - + 962 - +import alicia/identity/internal/ascii 963 - +import gleam/bit_array 964 - +import gleam/bool 965 - +import gleam/list 966 - +import gleam/result 967 - +import gleam/string 968 - + 969 - +/// A domain that passed inspection! 970 - +/// A parsed hostname. 971 - +/// One can rely on it having a head label, maybe some middle labels, 972 - +/// and a TLD. 973 - +pub opaque type Domain { 974 - + Domain(head: Label, rest: List(Label), tld: Tld) 975 - +} 976 - + 977 - +/// Has properties like only-one-dot separation, max 63 bytes, 978 - +/// no hyphens at edges. 979 - +pub opaque type Label { 980 - + Label(String) 981 - +} 982 - + 983 - +/// The final segment in a domain. 984 - +pub type Tld { 985 - + /// A normal TLD that can exist in normal DNS. 986 - + Registrable(label: TldLabel) 987 - + /// `.test` TLD, meant for examples, testing, & dev. 988 - + Test 989 - + /// A disallowed TLD, see `ReservedTld`. 990 - + Reserved(tld: ReservedTld) 991 - +} 992 - + 993 - +/// The label text of a registrable TLD, 994 - +/// which is just like a regular `Label` with no leading digit. 995 - +pub opaque type TldLabel { 996 - + TldLabel(String) 997 - +} 998 - + 999 - +/// The list of disallowed TLDs. 1000 - +/// These still pass syntax validation. 1001 - +/// Registration & resolution will always fail for them. 1002 - +/// 1003 - +/// Read more about reserved TLDs: 1004 - +/// [en.wikipedia.org/wiki/Top-level_domain#Reserved_domains](https://en.wikipedia.org/wiki/Top-level_domain#Reserved_domains) 1005 - +pub type ReservedTld { 1006 - + Alt 1007 - + Arpa 1008 - + Example 1009 - + Internal 1010 - + /// Only used in atproto for the special `handle.invalid` value. 1011 - + Invalid 1012 - + Local 1013 - + Localhost 1014 - + /// Tor hidden services. 1015 - + /// Though maybe they'll be allowed one day! 1016 - + Onion 1017 - +} 1018 - + 1019 - +/// Every way in which the `parse` function can reject an input. 1020 - +pub type ParseError { 1021 - + /// Beyond the 253-byte limit. 1022 - + TooLong 1023 - + /// A bare TLD. 1024 - + /// Domains need at least two segments. 1025 - + OneSegment 1026 - + /// An empty segment, from a leading, trailing, or doubled dot. 1027 - + EmptyLabel 1028 - + /// A segment over 63 bytes. 1029 - + LabelTooLong 1030 - + /// A byte which isn't a lowercase letter, digit, or hyphen. 1031 - + InvalidCharacter 1032 - + /// A segment that starts/ends with a hyphen. 1033 - + HyphenAtLabelEdge 1034 - + /// The final segment starts with a digit. 1035 - + /// This double to reject IPv4 addresses. 1036 - + TldLeadsWithDigit 1037 - +} 1038 - + 1039 - +/// The hostname byte limit from DNS name limits. 1040 - +pub const max_byte_length = 253 1041 - + 1042 - +const max_label_length = 63 1043 - + 1044 - +/// Domain construction: 1045 - +/// Validates hostname syntax. 1046 - +/// We reject uppercase input here. 1047 - +/// `handle.parse` normalizes case first. 1048 - +/// 1049 - +/// For example: 1050 - +/// - `parse("oyster.cafe")` -> `Ok(...)`; 1051 - +/// - `parse("Oyster.cafe")` -> `Error(InvalidCharacter)`; 1052 - +/// - `parse("org")` -> `Error(OneSegment)`. 1053 - +pub fn parse(raw: String) -> Result(Domain, ParseError) { 1054 - + use <- bool.guard(string.byte_size(raw) > max_byte_length, Error(TooLong)) 1055 - + case string.split(raw, ".") { 1056 - + [first, next, ..rest] -> { 1057 - + use head <- result.try(parse_label(first)) 1058 - + parse_rest(head, next, rest, []) 1059 - + } 1060 - + [only] -> result.try(parse_label(only), fn(_) { Error(OneSegment) }) 1061 - + [] -> Error(OneSegment) 1062 - + } 1063 - +} 1064 - + 1065 - +fn parse_rest( 1066 - + head: Label, 1067 - + segment: String, 1068 - + rest: List(String), 1069 - + acc: List(Label), 1070 - +) -> Result(Domain, ParseError) { 1071 - + case rest { 1072 - + [] -> { 1073 - + use tld <- result.try(parse_tld(segment)) 1074 - + Ok(Domain(head, list.reverse(acc), tld)) 1075 - + } 1076 - + [next, ..remaining] -> { 1077 - + use label <- result.try(parse_label(segment)) 1078 - + parse_rest(head, next, remaining, [label, ..acc]) 1079 - + } 1080 - + } 1081 - +} 1082 - + 1083 - +fn parse_label(raw: String) -> Result(Label, ParseError) { 1084 - + let bytes = bit_array.from_string(raw) 1085 - + let size = bit_array.byte_size(bytes) 1086 - + use <- bool.guard(size == 0, Error(EmptyLabel)) 1087 - + use <- bool.guard(size > max_label_length, Error(LabelTooLong)) 1088 - + use <- bool.guard( 1089 - + !ascii.every(bytes, ascii.is_lower_alnum_or_hyphen), 1090 - + Error(InvalidCharacter), 1091 - + ) 1092 - + case string.starts_with(raw, "-") || string.ends_with(raw, "-") { 1093 - + True -> Error(HyphenAtLabelEdge) 1094 - + False -> Ok(Label(raw)) 1095 - + } 1096 - +} 1097 - + 1098 - +fn parse_tld(raw: String) -> Result(Tld, ParseError) { 1099 - + use label <- result.try(parse_label(raw)) 1100 - + let Label(value) = label 1101 - + let leads_with_digit = case bit_array.from_string(value) { 1102 - + <<first, _:bits>> -> ascii.is_digit(first) 1103 - + _ -> False 1104 - + } 1105 - + case leads_with_digit { 1106 - + True -> Error(TldLeadsWithDigit) 1107 - + False -> Ok(classify_tld(value)) 1108 - + } 1109 - +} 1110 - + 1111 - +fn classify_tld(value: String) -> Tld { 1112 - + case value { 1113 - + "test" -> Test 1114 - + "alt" -> Reserved(Alt) 1115 - + "arpa" -> Reserved(Arpa) 1116 - + "example" -> Reserved(Example) 1117 - + "internal" -> Reserved(Internal) 1118 - + "invalid" -> Reserved(Invalid) 1119 - + "local" -> Reserved(Local) 1120 - + "localhost" -> Reserved(Localhost) 1121 - + "onion" -> Reserved(Onion) 1122 - + _ -> Registrable(TldLabel(value)) 1123 - + } 1124 - +} 1125 - + 1126 - +/// The inverse of `parse`: joining the labels & TLD back with dots. 1127 - +pub fn to_string(domain: Domain) -> String { 1128 - + let Domain(head, rest, tld) = domain 1129 - + let labels = list.map([head, ..rest], label_to_string) 1130 - + string.join(list.append(labels, [tld_to_string(tld)]), ".") 1131 - +} 1132 - + 1133 - +/// Every label before the TLD in original order. 1134 - +pub fn leading_labels(domain: Domain) -> List(Label) { 1135 - + let Domain(head, rest, _) = domain 1136 - + [head, ..rest] 1137 - +} 1138 - + 1139 - +/// The final segment of a given domain. 1140 - +pub fn tld(domain: Domain) -> Tld { 1141 - + domain.tld 1142 - +} 1143 - + 1144 - +/// Unwraps a `Label` to its text. 1145 - +pub fn label_to_string(label: Label) -> String { 1146 - + let Label(value) = label 1147 - + value 1148 - +} 1149 - + 1150 - +/// Unwraps a `TldLabel` to its text. 1151 - +pub fn tld_label_to_string(label: TldLabel) -> String { 1152 - + let TldLabel(value) = label 1153 - + value 1154 - +} 1155 - + 1156 - +/// Renders any `Tld` back to its label text. 1157 - +pub fn tld_to_string(tld: Tld) -> String { 1158 - + case tld { 1159 - + Registrable(label) -> tld_label_to_string(label) 1160 - + Test -> "test" 1161 - + Reserved(reserved) -> reserved_tld_to_string(reserved) 1162 - + } 1163 - +} 1164 - + 1165 - +/// The text of each reserved TLD. 1166 - +pub fn reserved_tld_to_string(reserved: ReservedTld) -> String { 1167 - + case reserved { 1168 - + Alt -> "alt" 1169 - + Arpa -> "arpa" 1170 - + Example -> "example" 1171 - + Internal -> "internal" 1172 - + Invalid -> "invalid" 1173 - + Local -> "local" 1174 - + Localhost -> "localhost" 1175 - + Onion -> "onion" 1176 - + } 1177 - +} 1178 - diff --git a/identity/src/alicia/identity/handle.gleam b/identity/src/alicia/identity/handle.gleam 1179 - new file mode 100644 1180 - index 0000000..52a0f2a 1181 - --- /dev/null 1182 - +++ b/identity/src/alicia/identity/handle.gleam 1183 - @@ -0,0 +1,73 @@ 1184 - +//// # Handle 1185 - +//// 1186 - +//// Official Documentation: [atproto.com/specs/handle](https://atproto.com/specs/handle) 1187 - +//// 1188 - +//// Handles are the mutable, human-friendly account names of atproto, 1189 - +//// in the form of a DNS hostname. 1190 - +//// Keep in mind that the DID stays as the account's permanent identifier. 1191 - +//// A handle only means something once it has been verified 1192 - +//// in both directions: a DID document must claim a handle, 1193 - +//// and a handle must resolve to that same DID. 1194 - +//// 1195 - +//// Some examples of handles are 1196 - +//// - `oyster.cafe` 1197 - +//// - `lesbian.skin` 1198 - +//// - `onedeuxtriseigo.nullpo.dev` 1199 - +//// 1200 - +//// This module does the parsing & normalization. 1201 - +//// Resolving a handle to its DID happens using a DNS TXT record 1202 - +//// or an HTTPS well-known endpoint. 1203 - + 1204 - +import alicia/identity/domain.{type Domain} 1205 - +import alicia/identity/internal/ascii 1206 - +import gleam/bit_array 1207 - +import gleam/bool 1208 - +import gleam/result 1209 - +import gleam/string 1210 - + 1211 - +/// A syntactically valid handle, normalized lowercase. 1212 - +/// Wraps a `domain.Domain` that already passed inspection. 1213 - +pub opaque type Handle { 1214 - + Handle(domain: Domain) 1215 - +} 1216 - + 1217 - +/// Normalizes ASCII `A-Z` to `a-z`, 1218 - +/// then calls `domain.parse` on the result. 1219 - +/// The ASCII check runs before lowercasing. 1220 - +/// Performing in this order keeps out Unicode which lowercases into ASCII, 1221 - +/// like the Kelvin sign becoming a `k`. 1222 - +/// 1223 - +/// For ex, `parse("WITCHSKY.APP")` is spat back out as `witchsky.app`. 1224 - +pub fn parse(raw: String) -> Result(Handle, domain.ParseError) { 1225 - + use <- bool.guard( 1226 - + string.byte_size(raw) > domain.max_byte_length, 1227 - + Error(domain.TooLong), 1228 - + ) 1229 - + let bytes = bit_array.from_string(raw) 1230 - + use <- bool.guard( 1231 - + !ascii.every(bytes, ascii.is_ascii), 1232 - + Error(domain.InvalidCharacter), 1233 - + ) 1234 - + result.map(domain.parse(string.lowercase(raw)), Handle) 1235 - +} 1236 - + 1237 - +/// Unwraps a `Handle` to its `domain.Domain`. 1238 - +pub fn domain(handle: Handle) -> Domain { 1239 - + handle.domain 1240 - +} 1241 - + 1242 - +/// Renders a `Handle` back to its text. 1243 - +pub fn to_string(handle: Handle) -> String { 1244 - + domain.to_string(handle.domain) 1245 - +} 1246 - + 1247 - +/// The special `handle.invalid` case: 1248 - +/// This denotes a DID whose handle no longer verifies (in both directions), 1249 - +/// and gets rejected in most situations. 1250 - +/// 1251 - +/// Read more about invalid handles: 1252 - +/// [atproto.com/specs/handle#invalid-handles](https://atproto.com/specs/handle#invalid-handles) 1253 - +pub fn invalid() -> Handle { 1254 - + let assert Ok(parsed) = domain.parse("handle.invalid") 1255 - + Handle(parsed) 1256 - +} 1257 - diff --git a/identity/src/alicia/identity/internal/ascii.gleam b/identity/src/alicia/identity/internal/ascii.gleam 1258 - new file mode 100644 1259 - index 0000000..d9deb2b 1260 - --- /dev/null 1261 - +++ b/identity/src/alicia/identity/internal/ascii.gleam 1262 - @@ -0,0 +1,59 @@ 1263 - +pub fn every(bytes: BitArray, predicate: fn(Int) -> Bool) -> Bool { 1264 - + case bytes { 1265 - + <<>> -> True 1266 - + <<b, rest:bits>> -> 1267 - + case predicate(b) { 1268 - + True -> every(rest, predicate) 1269 - + False -> False 1270 - + } 1271 - + _ -> False 1272 - + } 1273 - +} 1274 - + 1275 - +pub fn scan( 1276 - + bytes: BitArray, 1277 - + mid: fn(Int) -> Bool, 1278 - + last: fn(Int) -> Bool, 1279 - +) -> Bool { 1280 - + case bytes { 1281 - + <<b>> -> last(b) 1282 - + <<b, rest:bits>> -> 1283 - + case mid(b) { 1284 - + True -> scan(rest, mid, last) 1285 - + False -> False 1286 - + } 1287 - + _ -> False 1288 - + } 1289 - +} 1290 - + 1291 - +pub fn is_ascii(b: Int) -> Bool { 1292 - + b <= 0x7f 1293 - +} 1294 - + 1295 - +pub fn is_lower_alpha(b: Int) -> Bool { 1296 - + b >= 0x61 && b <= 0x7a 1297 - +} 1298 - + 1299 - +fn is_alpha(b: Int) -> Bool { 1300 - + case b { 1301 - + _ if b >= 0x61 && b <= 0x7a -> True 1302 - + _ if b >= 0x41 && b <= 0x5a -> True 1303 - + _ -> False 1304 - + } 1305 - +} 1306 - + 1307 - +pub fn is_digit(b: Int) -> Bool { 1308 - + b >= 0x30 && b <= 0x39 1309 - +} 1310 - + 1311 - +pub fn is_alnum(b: Int) -> Bool { 1312 - + is_alpha(b) || is_digit(b) 1313 - +} 1314 - + 1315 - +fn is_lower_alnum(b: Int) -> Bool { 1316 - + is_lower_alpha(b) || is_digit(b) 1317 - +} 1318 - + 1319 - +pub fn is_lower_alnum_or_hyphen(b: Int) -> Bool { 1320 - + b == 0x2d || is_lower_alnum(b) 1321 - +} 1322 - diff --git a/identity/test/alicia_identity_test.gleam b/identity/test/alicia_identity_test.gleam 1323 - index e7e7640..146b18b 100644 1324 - --- a/identity/test/alicia_identity_test.gleam 1325 - +++ b/identity/test/alicia_identity_test.gleam 1326 - @@ -1,6 +1,10 @@ 1327 - import alicia/identity/did 1328 - +import alicia/identity/domain 1329 - +import alicia/identity/handle 1330 - +import alicia/identity/internal/ascii 1331 - +import gleam/bit_array 1332 - import gleam/list 1333 - -import gleam/option.{None, Some} 1334 - +import gleam/result 1335 - import gleam/string 1336 - import gleeunit 1337 - 1338 - @@ -19,40 +23,35 @@ pub fn parse_web_test() { 1339 - } 1340 - 1341 - pub fn web_localhost_is_allowed_test() { 1342 - - let assert Ok(did.Web(host)) = did.parse("did:web:localhost") 1343 - - assert did.web_host_is_localhost(host) == True 1344 - - assert did.web_host_port(host) == None 1345 - - assert did.web_host_to_string(host) == "localhost" 1346 - -} 1347 - - 1348 - -pub fn web_localhost_with_port_is_allowed_test() { 1349 - - let assert Ok(did.Web(host)) = did.parse("did:web:localhost%3A3000") 1350 - - assert did.web_host_is_localhost(host) == True 1351 - - let assert Some(port) = did.web_host_port(host) 1352 - - assert did.port_to_int(port) == 3000 1353 - -} 1354 - - 1355 - -pub fn web_localhost_lowercase_hex_port_is_parsed_and_canonicalized_test() { 1356 - - let assert Ok(parsed) = did.parse("did:web:localhost%3a8080") 1357 - - let assert did.Web(host) = parsed 1358 - - let assert Some(port) = did.web_host_port(host) 1359 - - assert did.port_to_int(port) == 8080 1360 - - assert did.to_string(parsed) == "did:web:localhost%3A8080" 1361 - + [ 1362 - + #("did:web:localhost", Error(Nil), "did:web:localhost"), 1363 - + #("did:web:localhost%3A1", Ok(1), "did:web:localhost%3A1"), 1364 - + #("did:web:localhost%3A3000", Ok(3000), "did:web:localhost%3A3000"), 1365 - + #("did:web:localhost%3A65535", Ok(65_535), "did:web:localhost%3A65535"), 1366 - + #("did:web:localhost%3a8080", Ok(8080), "did:web:localhost%3A8080"), 1367 - + ] 1368 - + |> list.each(fn(row) { 1369 - + let #(input, expected_port, canonical) = row 1370 - + let assert Ok(parsed) = did.parse(input) 1371 - + let assert did.Web(host) = parsed 1372 - + assert did.web_host_is_localhost(host) == True as input 1373 - + assert did.web_host_domain(host) == Error(Nil) as input 1374 - + assert result.map(did.web_host_port(host), did.port_to_int) == expected_port 1375 - + as input 1376 - + assert did.to_string(parsed) == canonical as input 1377 - + }) 1378 - } 1379 - 1380 - pub fn web_domain_is_not_localhost_test() { 1381 - let assert Ok(did.Web(host)) = did.parse("did:web:localhost.example.com") 1382 - assert did.web_host_is_localhost(host) == False 1383 - - assert did.web_host_port(host) == None 1384 - -} 1385 - - 1386 - -pub fn web_single_char_tld_is_allowed_test() { 1387 - - let assert Ok(did.Web(_)) = did.parse("did:web:example.t") 1388 - + assert did.web_host_port(host) == Error(Nil) 1389 - } 1390 - 1391 - -pub fn web_reserved_tld_passes_syntax_test() { 1392 - - let assert Ok(did.Web(_)) = did.parse("did:web:laptop.local") 1393 - - let assert Ok(did.Web(_)) = did.parse("did:web:blah.arpa") 1394 - +pub fn web_domain_tld_is_classified_test() { 1395 - + let assert Ok(did.Web(host)) = did.parse("did:web:barnacle.arpa") 1396 - + let assert Ok(parsed) = did.web_host_domain(host) 1397 - + assert domain.tld(parsed) == domain.Reserved(domain.Arpa) 1398 - } 1399 - 1400 - pub fn parse_unsupported_method_test() { 1401 - @@ -62,36 +61,36 @@ pub fn parse_unsupported_method_test() { 1402 - assert did.identifier_to_string(identifier) == "289342934928347" 1403 - } 1404 - 1405 - -pub fn spec_valid_unsupported_examples_test() { 1406 - - let assert Ok(did.Other(_, _)) = did.parse("did:method:val:two") 1407 - - let assert Ok(did.Other(_, _)) = did.parse("did:m:v") 1408 - - let assert Ok(did.Other(_, _)) = did.parse("did:method::::val") 1409 - - let assert Ok(did.Other(_, _)) = did.parse("did:method:-:_:.") 1410 - - let assert Ok(did.Other(_, _)) = 1411 - - did.parse("did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N") 1412 - +pub fn web_rejects_test() { 1413 - + [ 1414 - + #("did:web:example", domain.OneSegment), 1415 - + #("did:web:NEL.PET", domain.InvalidCharacter), 1416 - + #("did:web:sheetr.app%3A3000", domain.InvalidCharacter), 1417 - + #("did:web:sheetr.123", domain.TldLeadsWithDigit), 1418 - + #("did:web:cn.8", domain.TldLeadsWithDigit), 1419 - + #("did:web:127.0.0.1", domain.TldLeadsWithDigit), 1420 - + #("did:web:" <> string.repeat("a", 64) <> ".app", domain.LabelTooLong), 1421 - + #("did:web:" <> longest_domain() <> "b", domain.TooLong), 1422 - + #("did:web:sheetr.app.", domain.EmptyLabel), 1423 - + #("did:web:sheetr.app:user:nel", domain.InvalidCharacter), 1424 - + ] 1425 - + |> list.each(fn(pair) { 1426 - + let #(input, expected) = pair 1427 - + assert did.parse(input) == Error(did.InvalidWebHost(expected)) as input 1428 - + }) 1429 - } 1430 - 1431 - -pub fn web_rejects_test() { 1432 - +pub fn web_port_rejects_test() { 1433 - [ 1434 - - "did:web:example", 1435 - "did:web:localhost%3A007", 1436 - "did:web:localhost%3A00", 1437 - "did:web:localhost%3A0", 1438 - + "did:web:localhost%3A65536", 1439 - "did:web:localhost%3A99999", 1440 - "did:web:localhost%3A", 1441 - - "did:web:Sheetr.app", 1442 - - "did:web:sheetr.App", 1443 - - "did:web:EXAMPLE.COM", 1444 - - "did:web:sheetr.app%3A3000", 1445 - - "did:web:sheetr.123", 1446 - - "did:web:cn.8", 1447 - - "did:web:127.0.0.1", 1448 - - "did:web:" <> string.repeat("a", 64) <> ".app", 1449 - - "did:web:sheetr.app.", 1450 - - "did:web:sheetr.app:user:nel", 1451 - ] 1452 - |> list.each(fn(input) { 1453 - - assert did.parse(input) == Error(did.InvalidWebHost) as input 1454 - + assert did.parse(input) == Error(did.InvalidPort) as input 1455 - }) 1456 - } 1457 - 1458 - @@ -109,6 +108,7 @@ pub fn rejects_invalid_dids_test() { 1459 - #("dfkg hdkvdfgnkghd", did.NotADid), 1460 - #("did:newthing", did.InvalidIdentifier), 1461 - #("did:NEWTHING", did.InvalidMethod), 1462 - + #("did:plc:" <> string.repeat("a", 2048), did.TooLong), 1463 - ] 1464 - |> list.each(fn(pair) { 1465 - let #(input, expected) = pair 1466 - @@ -116,11 +116,6 @@ pub fn rejects_invalid_dids_test() { 1467 - }) 1468 - } 1469 - 1470 - -pub fn too_long_is_rejected_test() { 1471 - - let long = "did:plc:" <> string.repeat("a", 2048) 1472 - - assert did.parse(long) == Error(did.TooLong) 1473 - -} 1474 - - 1475 - pub fn round_trip_test() { 1476 - let inputs = [ 1477 - "did:plc:ew3qowq4bsc67qohlxcbmsdb", 1478 - @@ -128,6 +123,11 @@ pub fn round_trip_test() { 1479 - "did:web:localhost", 1480 - "did:web:localhost%3A3000", 1481 - "did:newthing:289342934928347", 1482 - + "did:method:val:two", 1483 - + "did:m:v", 1484 - + "did:method::::val", 1485 - + "did:method:-:_:.", 1486 - + "did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N", 1487 - ] 1488 - list.each(inputs, fn(input) { 1489 - let assert Ok(parsed) = did.parse(input) 1490 - @@ -135,19 +135,130 @@ pub fn round_trip_test() { 1491 - }) 1492 - } 1493 - 1494 - -pub fn method_dispatch_is_exhaustive_test() { 1495 - - let assert Ok(plc) = did.parse("did:plc:ew3qowq4bsc67qohlxcbmsdb") 1496 - - let assert Ok(web) = did.parse("did:web:sheetr.app") 1497 - - let assert Ok(other) = did.parse("did:newthing:289342934928347") 1498 - - assert describe(plc) == "plc" 1499 - - assert describe(web) == "web" 1500 - - assert describe(other) == "newthing" 1501 - +pub fn handle_parse_test() { 1502 - + let assert Ok(parsed) = handle.parse("nel.oyster.cafe") 1503 - + assert handle.to_string(parsed) == "nel.oyster.cafe" 1504 - + let parsed_domain = handle.domain(parsed) 1505 - + assert list.map(domain.leading_labels(parsed_domain), domain.label_to_string) 1506 - + == ["nel", "oyster"] 1507 - + assert domain.tld_to_string(domain.tld(parsed_domain)) == "cafe" 1508 - +} 1509 - + 1510 - +pub fn handle_normalizes_case_test() { 1511 - + let assert Ok(parsed) = handle.parse("XX.LCS.MIT.EDU") 1512 - + assert handle.to_string(parsed) == "xx.lcs.mit.edu" 1513 - + assert handle.parse("BlueskyWeb.xyz") == handle.parse("blueskyweb.xyz") 1514 - +} 1515 - + 1516 - +pub fn handle_spec_valid_examples_round_trip_test() { 1517 - + [ 1518 - + "oyster.cafe", 1519 - + "8.cn", 1520 - + "name.t--t", 1521 - + "a.co", 1522 - + "xn--notarealidn.com", 1523 - + "xn--fiqa61au8b7zsevnm8ak20mc4a87e.xn--fiqs8s", 1524 - + "xn--ls8h.test", 1525 - + "example.t", 1526 - + ] 1527 - + |> list.each(fn(input) { 1528 - + let assert Ok(parsed) = handle.parse(input) 1529 - + assert handle.to_string(parsed) == input as input 1530 - + }) 1531 - +} 1532 - + 1533 - +pub fn handle_rejects_test() { 1534 - + [ 1535 - + #("jo@hn.test", domain.InvalidCharacter), 1536 - + #("💩.test", domain.InvalidCharacter), 1537 - + #("www.masełkowski.pl.com", domain.InvalidCharacter), 1538 - + #("\u{212A}elvin.com", domain.InvalidCharacter), 1539 - + #("john..test", domain.EmptyLabel), 1540 - + #("name.org.", domain.EmptyLabel), 1541 - + #(".oyster.cafe", domain.EmptyLabel), 1542 - + #("xn--bcher-.tld", domain.HyphenAtLabelEdge), 1543 - + #("-clam.cafe", domain.HyphenAtLabelEdge), 1544 - + #("john.0", domain.TldLeadsWithDigit), 1545 - + #("cn.8", domain.TldLeadsWithDigit), 1546 - + #("org", domain.OneSegment), 1547 - + #("", domain.EmptyLabel), 1548 - + ] 1549 - + |> list.each(fn(pair) { 1550 - + let #(input, expected) = pair 1551 - + assert handle.parse(input) == Error(expected) as input 1552 - + }) 1553 - +} 1554 - + 1555 - +pub fn handle_length_limits_test() { 1556 - + let label = string.repeat("a", 63) 1557 - + let assert Ok(_) = handle.parse(label <> ".cafe") 1558 - + assert handle.parse(string.repeat("a", 64) <> ".cafe") 1559 - + == Error(domain.LabelTooLong) 1560 - + assert string.byte_size(longest_domain()) == 253 1561 - + let assert Ok(_) = handle.parse(longest_domain()) 1562 - + assert handle.parse(longest_domain() <> "b") == Error(domain.TooLong) 1563 - + assert handle.parse(string.repeat("a", 1_000_000)) == Error(domain.TooLong) 1564 - +} 1565 - + 1566 - +fn longest_domain() -> String { 1567 - + let label = string.repeat("a", 63) 1568 - + label <> "." <> label <> "." <> label <> "." <> string.repeat("b", 61) 1569 - +} 1570 - + 1571 - +pub fn ascii_predicates_are_stack_safe_test() { 1572 - + let bytes = bit_array.from_string(string.repeat("a", 1_000_000)) 1573 - + assert ascii.every(bytes, ascii.is_ascii) 1574 - + assert ascii.scan(bytes, ascii.is_ascii, ascii.is_ascii) 1575 - +} 1576 - + 1577 - +pub fn reserved_tld_round_trip_test() { 1578 - + let onion_address = "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid" 1579 - + [ 1580 - + #("conch", domain.Alt), 1581 - + #("conch", domain.Arpa), 1582 - + #("conch", domain.Example), 1583 - + #("conch", domain.Internal), 1584 - + #("conch", domain.Invalid), 1585 - + #("conch", domain.Local), 1586 - + #("conch", domain.Localhost), 1587 - + #(onion_address, domain.Onion), 1588 - + ] 1589 - + |> list.each(fn(pair) { 1590 - + let #(prefix, reserved) = pair 1591 - + let rendered = prefix <> "." <> domain.reserved_tld_to_string(reserved) 1592 - + let assert Ok(parsed) = handle.parse(rendered) 1593 - + assert domain.tld(handle.domain(parsed)) == domain.Reserved(reserved) 1594 - + as rendered 1595 - + assert handle.to_string(parsed) == rendered as rendered 1596 - + }) 1597 - +} 1598 - + 1599 - +pub fn handle_invalid_test() { 1600 - + let assert Ok(parsed) = handle.parse("handle.invalid") 1601 - + assert parsed == handle.invalid() 1602 - + assert handle.to_string(handle.invalid()) == "handle.invalid" 1603 - +} 1604 - + 1605 - +pub fn handle_tld_dispatch_is_exhaustive_test() { 1606 - + let assert Ok(registrable) = handle.parse("lyna.witchcraft.systems") 1607 - + let assert Ok(dev) = handle.parse("bailey.test") 1608 - + let assert Ok(reserved) = handle.parse("mussel.onion") 1609 - + assert describe_tld(domain.tld(handle.domain(registrable))) == "systems" 1610 - + assert describe_tld(domain.tld(handle.domain(dev))) == "test" 1611 - + assert describe_tld(domain.tld(handle.domain(reserved))) == "reserved:onion" 1612 - } 1613 - 1614 - -fn describe(value: did.Did) -> String { 1615 - - case value { 1616 - - did.Plc(_) -> "plc" 1617 - - did.Web(_) -> "web" 1618 - - did.Other(method, _) -> did.method_name_to_string(method) 1619 - +fn describe_tld(tld: domain.Tld) -> String { 1620 - + case tld { 1621 - + domain.Registrable(label) -> domain.tld_label_to_string(label) 1622 - + domain.Test -> "test" 1623 - + domain.Reserved(reserved) -> 1624 - + "reserved:" <> domain.reserved_tld_to_string(reserved) 1625 - } 1626 - } 1627 - + 1628 - +pub fn domain_is_case_strict_test() { 1629 - + assert domain.parse("Oyster.cafe") == Error(domain.InvalidCharacter) 1630 - + assert domain.parse("oyster.Cafe") == Error(domain.InvalidCharacter) 1631 - + let assert Ok(_) = domain.parse("oyster.cafe") 1632 - +} 1633 -
+1
identity/gleam.toml
··· 1 1 name = "alicia_identity" 2 2 version = "1.0.0" 3 + gleam = ">= 1.11.0" 3 4 4 5 description = "atproto identity library" 5 6 licences = ["Apache-2.0"]
+31 -9
identity/src/alicia/identity/domain.gleam
··· 67 67 /// A bare TLD. 68 68 /// Domains need at least two segments. 69 69 OneSegment 70 + InvalidLabel(reason: LabelError) 71 + } 72 + 73 + /// Failures scoped to a single label. 74 + /// `parse` wraps these in `InvalidLabel`. 75 + /// `parse_segments` returns them bare. 76 + pub type LabelError { 70 77 /// An empty segment, from a leading, trailing, or doubled dot. 71 78 EmptyLabel 72 79 /// A segment over 63 bytes. ··· 92 99 /// 93 100 /// For example: 94 101 /// - `parse("oyster.cafe")` -> `Ok(...)`; 95 - /// - `parse("Oyster.cafe")` -> `Error(InvalidCharacter)`; 102 + /// - `parse("Oyster.cafe")` -> `Error(InvalidLabel(InvalidCharacter))`; 96 103 /// - `parse("org")` -> `Error(OneSegment)`. 97 104 pub fn parse(raw: String) -> Result(Domain, ParseError) { 98 105 use <- bool.guard(string.byte_size(raw) > max_byte_length, Error(TooLong)) 99 106 case string.split(raw, ".") { 100 - [first, next, ..rest] -> { 101 - use head <- result.try(parse_label(first)) 102 - parse_rest(head, next, rest, []) 103 - } 104 - [only] -> result.try(parse_label(only), fn(_) { Error(OneSegment) }) 107 + [first, next, ..rest] -> 108 + result.map_error(parse_segments(first, next, rest), InvalidLabel) 109 + [only] -> 110 + case parse_label(only) { 111 + Ok(_) -> Error(OneSegment) 112 + Error(reason) -> Error(InvalidLabel(reason)) 113 + } 105 114 [] -> Error(OneSegment) 106 115 } 107 116 } 108 117 118 + /// The entry for callers that already split & lowercased their input. 119 + /// The 253-byte total limit does not apply, 120 + /// since NSID authorities may exceed it. 121 + /// alicia_core's `nsid` module is the consumer. 122 + pub fn parse_segments( 123 + first: String, 124 + next: String, 125 + rest: List(String), 126 + ) -> Result(Domain, LabelError) { 127 + use head <- result.try(parse_label(first)) 128 + parse_rest(head, next, rest, []) 129 + } 130 + 109 131 fn parse_rest( 110 132 head: Label, 111 133 segment: String, 112 134 rest: List(String), 113 135 acc: List(Label), 114 - ) -> Result(Domain, ParseError) { 136 + ) -> Result(Domain, LabelError) { 115 137 case rest { 116 138 [] -> { 117 139 use tld <- result.try(parse_tld(segment)) ··· 124 146 } 125 147 } 126 148 127 - fn parse_label(raw: String) -> Result(Label, ParseError) { 149 + fn parse_label(raw: String) -> Result(Label, LabelError) { 128 150 let bytes = bit_array.from_string(raw) 129 151 let size = bit_array.byte_size(bytes) 130 152 use <- bool.guard(size == 0, Error(EmptyLabel)) ··· 139 161 } 140 162 } 141 163 142 - fn parse_tld(raw: String) -> Result(Tld, ParseError) { 164 + fn parse_tld(raw: String) -> Result(Tld, LabelError) { 143 165 use label <- result.try(parse_label(raw)) 144 166 let Label(value) = label 145 167 let leads_with_digit = case bit_array.from_string(value) {
+1 -1
identity/src/alicia/identity/handle.gleam
··· 46 46 let bytes = bit_array.from_string(raw) 47 47 use <- bool.guard( 48 48 !ascii.every(bytes, ascii.is_ascii), 49 - Error(domain.InvalidCharacter), 49 + Error(domain.InvalidLabel(domain.InvalidCharacter)), 50 50 ) 51 51 result.map(domain.parse(string.lowercase(raw)), Handle) 52 52 }
+42 -30
identity/test/alicia_identity_test.gleam
··· 64 64 pub fn web_rejects_test() { 65 65 [ 66 66 #("did:web:example", domain.OneSegment), 67 - #("did:web:NEL.PET", domain.InvalidCharacter), 68 - #("did:web:sheetr.app%3A3000", domain.InvalidCharacter), 69 - #("did:web:sheetr.123", domain.TldLeadsWithDigit), 70 - #("did:web:cn.8", domain.TldLeadsWithDigit), 71 - #("did:web:127.0.0.1", domain.TldLeadsWithDigit), 72 - #("did:web:" <> string.repeat("a", 64) <> ".app", domain.LabelTooLong), 67 + #("did:web:NEL.PET", domain.InvalidLabel(domain.InvalidCharacter)), 68 + #("did:web:sheetr.app%3A3000", domain.InvalidLabel(domain.InvalidCharacter)), 69 + #("did:web:sheetr.123", domain.InvalidLabel(domain.TldLeadsWithDigit)), 70 + #("did:web:cn.8", domain.InvalidLabel(domain.TldLeadsWithDigit)), 71 + #("did:web:127.0.0.1", domain.InvalidLabel(domain.TldLeadsWithDigit)), 72 + #( 73 + "did:web:" <> string.repeat("a", 64) <> ".app", 74 + domain.InvalidLabel(domain.LabelTooLong), 75 + ), 73 76 #("did:web:" <> longest_domain() <> "b", domain.TooLong), 74 - #("did:web:sheetr.app.", domain.EmptyLabel), 75 - #("did:web:sheetr.app:user:nel", domain.InvalidCharacter), 77 + #("did:web:sheetr.app.", domain.InvalidLabel(domain.EmptyLabel)), 78 + #( 79 + "did:web:sheetr.app:user:nel", 80 + domain.InvalidLabel(domain.InvalidCharacter), 81 + ), 76 82 ] 77 83 |> list.each(fn(pair) { 78 84 let #(input, expected) = pair ··· 144 150 assert domain.tld_to_string(domain.tld(parsed_domain)) == "cafe" 145 151 } 146 152 147 - pub fn handle_normalizes_case_test() { 153 + pub fn case_handling_test() { 148 154 let assert Ok(parsed) = handle.parse("XX.LCS.MIT.EDU") 149 155 assert handle.to_string(parsed) == "xx.lcs.mit.edu" 150 156 assert handle.parse("BlueskyWeb.xyz") == handle.parse("blueskyweb.xyz") 157 + assert domain.parse("Oyster.cafe") 158 + == Error(domain.InvalidLabel(domain.InvalidCharacter)) 159 + assert domain.parse("oyster.Cafe") 160 + == Error(domain.InvalidLabel(domain.InvalidCharacter)) 161 + let assert Ok(_) = domain.parse("oyster.cafe") 151 162 } 152 163 153 164 pub fn handle_spec_valid_examples_round_trip_test() { ··· 169 180 170 181 pub fn handle_rejects_test() { 171 182 [ 172 - #("jo@hn.test", domain.InvalidCharacter), 173 - #("💩.test", domain.InvalidCharacter), 174 - #("www.masełkowski.pl.com", domain.InvalidCharacter), 175 - #("\u{212A}elvin.com", domain.InvalidCharacter), 176 - #("john..test", domain.EmptyLabel), 177 - #("name.org.", domain.EmptyLabel), 178 - #(".oyster.cafe", domain.EmptyLabel), 179 - #("xn--bcher-.tld", domain.HyphenAtLabelEdge), 180 - #("-clam.cafe", domain.HyphenAtLabelEdge), 181 - #("john.0", domain.TldLeadsWithDigit), 182 - #("cn.8", domain.TldLeadsWithDigit), 183 + #("jo@hn.test", domain.InvalidLabel(domain.InvalidCharacter)), 184 + #("💩.test", domain.InvalidLabel(domain.InvalidCharacter)), 185 + #("www.łewisowski.pl.com", domain.InvalidLabel(domain.InvalidCharacter)), 186 + #("\u{212A}elvin.com", domain.InvalidLabel(domain.InvalidCharacter)), 187 + #("john..test", domain.InvalidLabel(domain.EmptyLabel)), 188 + #("name.org.", domain.InvalidLabel(domain.EmptyLabel)), 189 + #(".oyster.cafe", domain.InvalidLabel(domain.EmptyLabel)), 190 + #("xn--bcher-.tld", domain.InvalidLabel(domain.HyphenAtLabelEdge)), 191 + #("-clam.cafe", domain.InvalidLabel(domain.HyphenAtLabelEdge)), 192 + #("john.0", domain.InvalidLabel(domain.TldLeadsWithDigit)), 193 + #("cn.8", domain.InvalidLabel(domain.TldLeadsWithDigit)), 183 194 #("org", domain.OneSegment), 184 - #("", domain.EmptyLabel), 195 + #("", domain.InvalidLabel(domain.EmptyLabel)), 185 196 ] 186 197 |> list.each(fn(pair) { 187 198 let #(input, expected) = pair ··· 189 200 }) 190 201 } 191 202 192 - pub fn handle_length_limits_test() { 203 + pub fn length_limits_test() { 193 204 let label = string.repeat("a", 63) 194 205 let assert Ok(_) = handle.parse(label <> ".cafe") 195 206 assert handle.parse(string.repeat("a", 64) <> ".cafe") 196 - == Error(domain.LabelTooLong) 207 + == Error(domain.InvalidLabel(domain.LabelTooLong)) 197 208 assert string.byte_size(longest_domain()) == 253 198 209 let assert Ok(_) = handle.parse(longest_domain()) 199 210 assert handle.parse(longest_domain() <> "b") == Error(domain.TooLong) 200 211 assert handle.parse(string.repeat("a", 1_000_000)) == Error(domain.TooLong) 212 + let long = longest_domain() <> ".b" 213 + assert domain.parse(long) == Error(domain.TooLong) 214 + let assert Ok(parsed) = 215 + domain.parse_segments(label, label, [label, string.repeat("b", 61), "b"]) 216 + assert domain.to_string(parsed) == long 217 + assert domain.parse_segments(string.repeat("a", 64), "cafe", []) 218 + == Error(domain.LabelTooLong) 201 219 } 202 220 203 221 fn longest_domain() -> String { ··· 212 230 } 213 231 214 232 pub fn reserved_tld_round_trip_test() { 215 - let onion_address = "2gzyxa5ihm7nsggfxnu52rck2vv4rvmdlkiu3zzui5du4xyclen53wid" 233 + let onion_address = "bqmkv4w3cwcpi6og6o3nfmflrzgc3rxp4l2rk77k7ybfvawfr5fuujid" 216 234 [ 217 235 #("conch", domain.Alt), 218 236 #("conch", domain.Arpa), ··· 256 274 "reserved:" <> domain.reserved_tld_to_string(reserved) 257 275 } 258 276 } 259 - 260 - pub fn domain_is_case_strict_test() { 261 - assert domain.parse("Oyster.cafe") == Error(domain.InvalidCharacter) 262 - assert domain.parse("oyster.Cafe") == Error(domain.InvalidCharacter) 263 - let assert Ok(_) = domain.parse("oyster.cafe") 264 - }