···11+namespace PDSharp.Core
22+33+open System
44+open System.Text.RegularExpressions
55+66+/// AT-URI parsing and validation
77+module AtUri =
88+ /// Represents an AT Protocol URI: at://did/collection/rkey
99+ type AtUri = { Did : string; Collection : string; Rkey : string }
1010+1111+ let private didPattern = @"^did:[a-z]+:[a-zA-Z0-9._:%-]+$"
1212+ let private nsidPattern = @"^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$"
1313+ let private rkeyPattern = @"^[a-zA-Z0-9._~-]+$"
1414+1515+ /// Parse an AT-URI string into components
1616+ let parse (uri : string) : Result<AtUri, string> =
1717+ if not (uri.StartsWith("at://")) then
1818+ Error "AT-URI must start with at://"
1919+ else
2020+ let path = uri.Substring(5)
2121+ let parts = path.Split('/')
2222+2323+ if parts.Length < 3 then
2424+ Error "AT-URI must have format at://did/collection/rkey"
2525+ else
2626+ let did = parts.[0]
2727+ let collection = parts.[1]
2828+ let rkey = parts.[2]
2929+3030+ if not (Regex.IsMatch(did, didPattern)) then
3131+ Error $"Invalid DID format: {did}"
3232+ elif not (Regex.IsMatch(collection, nsidPattern)) then
3333+ Error $"Invalid collection NSID: {collection}"
3434+ elif not (Regex.IsMatch(rkey, rkeyPattern)) then
3535+ Error $"Invalid rkey format: {rkey}"
3636+ else
3737+ Ok { Did = did; Collection = collection; Rkey = rkey }
3838+3939+ /// Convert AtUri back to string
4040+ let toString (uri : AtUri) : string =
4141+ $"at://{uri.Did}/{uri.Collection}/{uri.Rkey}"
+45
PDSharp.Core/BlockStore.fs
···11+namespace PDSharp.Core
22+33+open System.Collections.Concurrent
44+55+/// Block storage interface for CID โ byte[] mappings
66+module BlockStore =
77+88+ /// Interface for content-addressed block storage
99+ type IBlockStore =
1010+ abstract member Get : Cid -> Async<byte[] option>
1111+ abstract member Put : byte[] -> Async<Cid>
1212+ abstract member Has : Cid -> Async<bool>
1313+1414+ /// In-memory implementation of IBlockStore for testing
1515+ type MemoryBlockStore() =
1616+ let store = ConcurrentDictionary<string, byte[]>()
1717+1818+ let cidKey (cid : Cid) =
1919+ System.Convert.ToBase64String(cid.Bytes)
2020+2121+ interface IBlockStore with
2222+ member _.Get(cid : Cid) = async {
2323+ let key = cidKey cid
2424+ let success, data = store.TryGetValue(key)
2525+ return if success then Some data else None
2626+ }
2727+2828+ member _.Put(data : byte[]) = async {
2929+ let hash = Crypto.sha256 data
3030+ let cid = Cid.FromHash hash
3131+ let key = cidKey cid
3232+ store.[key] <- data
3333+ return cid
3434+ }
3535+3636+ member _.Has(cid : Cid) = async {
3737+ let key = cidKey cid
3838+ return store.ContainsKey(key)
3939+ }
4040+4141+ /// Get the number of blocks stored (for testing)
4242+ member _.Count = store.Count
4343+4444+ /// Clear all blocks (for testing)
4545+ member _.Clear() = store.Clear()
···11+namespace PDSharp.Core
22+33+open System
44+55+/// Repository commit signing and management
66+module Repository =
77+ /// TID (Timestamp ID) generation for revision IDs
88+ module Tid =
99+ let private chars = "234567abcdefghijklmnopqrstuvwxyz"
1010+ let private clockIdBits = 10
1111+ let private timestampBits = 53
1212+1313+ /// Generate a random clock ID component
1414+ let private randomClockId () =
1515+ let rng = Random()
1616+ rng.Next(1 <<< clockIdBits)
1717+1818+ /// Encode a number to base32 sortable string
1919+ let private encode (value : int64) (length : int) =
2020+ let mutable v = value
2121+ let arr = Array.zeroCreate<char> length
2222+2323+ for i in (length - 1) .. -1 .. 0 do
2424+ arr.[i] <- chars.[int (v &&& 0x1FL)]
2525+ v <- v >>> 5
2626+2727+ String(arr)
2828+2929+ /// Generate a new TID based on current timestamp
3030+ let generate () : string =
3131+ let timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
3232+ let clockId = randomClockId ()
3333+ let combined = (timestamp <<< clockIdBits) ||| int64 clockId
3434+ encode combined 13
3535+3636+ /// Unsigned commit record (before signing)
3737+ type UnsignedCommit = {
3838+ Did : string
3939+ Version : int
4040+ Data : Cid
4141+ Rev : string
4242+ Prev : Cid option
4343+ }
4444+4545+ /// Signed commit record
4646+ type SignedCommit = {
4747+ Did : string
4848+ Version : int
4949+ Data : Cid
5050+ Rev : string
5151+ Prev : Cid option
5252+ Sig : byte[]
5353+ }
5454+5555+ /// Convert unsigned commit to CBOR-encodable map
5656+ let private unsignedToCborMap (commit : UnsignedCommit) : Map<string, obj> =
5757+ let baseMap =
5858+ Map.ofList [
5959+ ("did", box commit.Did)
6060+ ("version", box commit.Version)
6161+ ("data", box commit.Data)
6262+ ("rev", box commit.Rev)
6363+ ]
6464+6565+ match commit.Prev with
6666+ | Some prev -> baseMap |> Map.add "prev" (box prev)
6767+ | None -> baseMap
6868+6969+ /// Sign an unsigned commit
7070+ let signCommit (key : Crypto.EcKeyPair) (commit : UnsignedCommit) : SignedCommit =
7171+ let cborMap = unsignedToCborMap commit
7272+ let cborBytes = DagCbor.encode cborMap
7373+ let hash = Crypto.sha256 cborBytes
7474+ let signature = Crypto.sign key hash
7575+7676+ {
7777+ Did = commit.Did
7878+ Version = commit.Version
7979+ Data = commit.Data
8080+ Rev = commit.Rev
8181+ Prev = commit.Prev
8282+ Sig = signature
8383+ }
8484+8585+ /// Verify a signed commit's signature
8686+ let verifyCommit (key : Crypto.EcKeyPair) (commit : SignedCommit) : bool =
8787+ let unsigned = {
8888+ Did = commit.Did
8989+ Version = commit.Version
9090+ Data = commit.Data
9191+ Rev = commit.Rev
9292+ Prev = commit.Prev
9393+ }
9494+9595+ let cborMap = unsignedToCborMap unsigned
9696+ let cborBytes = DagCbor.encode cborMap
9797+ let hash = Crypto.sha256 cborBytes
9898+ Crypto.verify key hash commit.Sig
9999+100100+ /// Convert signed commit to CBOR-encodable map
101101+ let signedToCborMap (commit : SignedCommit) : Map<string, obj> =
102102+ let baseMap =
103103+ Map.ofList [
104104+ ("did", box commit.Did)
105105+ ("version", box commit.Version)
106106+ ("data", box commit.Data)
107107+ ("rev", box commit.Rev)
108108+ ("sig", box commit.Sig)
109109+ ]
110110+111111+ match commit.Prev with
112112+ | Some prev -> baseMap |> Map.add "prev" (box prev)
113113+ | None -> baseMap
114114+115115+ /// Serialize a signed commit to DAG-CBOR bytes
116116+ let serializeCommit (commit : SignedCommit) : byte[] =
117117+ signedToCborMap commit |> DagCbor.encode
118118+119119+ /// Get CID for a signed commit
120120+ let commitCid (commit : SignedCommit) : Cid =
121121+ let bytes = serializeCommit commit
122122+ let hash = Crypto.sha256 bytes
123123+ Cid.FromHash hash
+74
PDSharp.Tests/AtUri.Tests.fs
···11+module AtUriTests
22+33+open Xunit
44+open PDSharp.Core.AtUri
55+66+[<Fact>]
77+let ``Parse valid AT-URI`` () =
88+ let uri = "at://did:plc:abcd1234/app.bsky.feed.post/3kbq5vk4beg2f"
99+ let result = parse uri
1010+1111+ match result with
1212+ | Ok parsed ->
1313+ Assert.Equal("did:plc:abcd1234", parsed.Did)
1414+ Assert.Equal("app.bsky.feed.post", parsed.Collection)
1515+ Assert.Equal("3kbq5vk4beg2f", parsed.Rkey)
1616+ | Error msg -> Assert.Fail(msg)
1717+1818+[<Fact>]
1919+let ``Parse did:web AT-URI`` () =
2020+ let uri = "at://did:web:example.com/app.bsky.actor.profile/self"
2121+ let result = parse uri
2222+2323+ match result with
2424+ | Ok parsed ->
2525+ Assert.Equal("did:web:example.com", parsed.Did)
2626+ Assert.Equal("app.bsky.actor.profile", parsed.Collection)
2727+ Assert.Equal("self", parsed.Rkey)
2828+ | Error msg -> Assert.Fail(msg)
2929+3030+[<Fact>]
3131+let ``Parse fails without at:// prefix`` () =
3232+ let uri = "http://did:plc:abcd/app.bsky.feed.post/123"
3333+ let result = parse uri
3434+3535+ Assert.True(Result.isError result)
3636+3737+[<Fact>]
3838+let ``Parse fails with invalid DID`` () =
3939+ let uri = "at://not-a-did/app.bsky.feed.post/123"
4040+ let result = parse uri
4141+4242+ Assert.True(Result.isError result)
4343+4444+[<Fact>]
4545+let ``Parse fails with invalid collection`` () =
4646+ let uri = "at://did:plc:abcd/NotAnNsid/123"
4747+ let result = parse uri
4848+4949+ Assert.True(Result.isError result)
5050+5151+[<Fact>]
5252+let ``Parse fails with missing parts`` () =
5353+ let uri = "at://did:plc:abcd/app.bsky.feed.post"
5454+ let result = parse uri
5555+5656+ Assert.True(Result.isError result)
5757+5858+[<Fact>]
5959+let ``ToString roundtrip`` () =
6060+ let original = {
6161+ Did = "did:plc:abcd"
6262+ Collection = "app.bsky.feed.post"
6363+ Rkey = "123"
6464+ }
6565+6666+ let str = toString original
6767+ let parsed = parse str
6868+6969+ match parsed with
7070+ | Ok p ->
7171+ Assert.Equal(original.Did, p.Did)
7272+ Assert.Equal(original.Collection, p.Collection)
7373+ Assert.Equal(original.Rkey, p.Rkey)
7474+ | Error msg -> Assert.Fail(msg)
+52
PDSharp.Tests/BlockStore.Tests.fs
···11+module BlockStoreTests
22+33+open Xunit
44+open PDSharp.Core
55+open PDSharp.Core.BlockStore
66+77+[<Fact>]
88+let ``MemoryBlockStore Put and Get roundtrip`` () =
99+ let store = MemoryBlockStore() :> IBlockStore
1010+ let data = System.Text.Encoding.UTF8.GetBytes("hello world")
1111+1212+ let cid = store.Put(data) |> Async.RunSynchronously
1313+ let retrieved = store.Get(cid) |> Async.RunSynchronously
1414+1515+ Assert.True(Option.isSome retrieved)
1616+ Assert.Equal<byte[]>(data, Option.get retrieved)
1717+1818+[<Fact>]
1919+let ``MemoryBlockStore Has returns true for existing`` () =
2020+ let store = MemoryBlockStore() :> IBlockStore
2121+ let data = System.Text.Encoding.UTF8.GetBytes("test data")
2222+2323+ let cid = store.Put(data) |> Async.RunSynchronously
2424+ let exists = store.Has(cid) |> Async.RunSynchronously
2525+2626+ Assert.True(exists)
2727+2828+[<Fact>]
2929+let ``MemoryBlockStore Has returns false for missing`` () =
3030+ let store = MemoryBlockStore() :> IBlockStore
3131+ let fakeCid = Cid.FromHash(Crypto.sha256Str "nonexistent")
3232+3333+ let exists = store.Has(fakeCid) |> Async.RunSynchronously
3434+3535+ Assert.False(exists)
3636+3737+[<Fact>]
3838+let ``MemoryBlockStore Get returns None for missing`` () =
3939+ let store = MemoryBlockStore() :> IBlockStore
4040+ let fakeCid = Cid.FromHash(Crypto.sha256Str "nonexistent")
4141+4242+ let result = store.Get(fakeCid) |> Async.RunSynchronously
4343+4444+ Assert.True(Option.isNone result)
4545+4646+[<Fact>]
4747+let ``MemoryBlockStore CID is content-addressed`` () =
4848+ let store = MemoryBlockStore() :> IBlockStore
4949+ let data = System.Text.Encoding.UTF8.GetBytes("same content")
5050+ let cid1 = store.Put data |> Async.RunSynchronously
5151+ let cid2 = store.Put data |> Async.RunSynchronously
5252+ Assert.Equal<byte[]>(cid1.Bytes, cid2.Bytes)