packages feed

moonlight-core-0.1.0.0: test/basis/StableHashSpec.hs

module StableHashSpec (tests) where

import qualified Data.ByteString as ByteString
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import Data.ByteString.Builder qualified as Builder
import Data.Foldable (traverse_)
import qualified Data.ByteString.Lazy as LazyByteString
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TextEncoding
import Data.Word (Word64, Word8)
import Moonlight.Core
  ( defaultStableHashKey,
    sipHashFinalize,
    sipHash24,
    sipHashDigest,
    sipHashInit,
    sipHashUpdateByteString,
    stableHashBuilder,
    stableHashByteStrings,
    stableHashEncodingByteString,
    stableHashEncodingChunks,
    stableHashEncodingDigest,
    stableHashEncodingTextUtf8,
    stableHashEncodingVersion,
    stableHashEncodingWord64Dec,
    stableHashEncodingWord64LE,
    stableHashEncodingWord8,
    unStableHashDigest,
  )
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))
import Test.Tasty.QuickCheck (testProperty, (===))

tests :: TestTree
tests =
  testGroup
    "stable_hash"
    [ testCase "sipHash24 is deterministic for identical inputs" $
        sipHash24 17 23 (BS8.pack "meridian") @?= sipHash24 17 23 (BS8.pack "meridian"),
      testCase "byte framing encoding is versioned" $
        stableHashEncodingVersion @?= 2,
      testCase "byte-string chunks are deterministically framed" $
        stableHashByteStrings [BS8.pack "moon", BS8.pack "light"]
          @?= stableHashByteStrings [BS8.pack "moon", BS8.pack "light"],
      testCase "builder payloads are deterministically hashed" $
        stableHashBuilder (Builder.string8 "moonlight")
          @?= stableHashBuilder (Builder.string8 "moonlight"),
      testCase "builder streaming matches materialized versioned preimage" $
        unStableHashDigest (stableHashBuilder (Builder.string8 "moonlight" <> Builder.word64LE 42))
          @?= sipHashDigest
            defaultStableHashKey
            ( LazyByteString.toStrict
                ( Builder.toLazyByteString
                    ( Builder.word64LE stableHashEncodingVersion
                        <> Builder.string8 "moonlight"
                        <> Builder.word64LE 42
                    )
                )
            ),
      testCase "encoding writers hash without materialized builder payloads" $
        stableHashEncodingDigest
          defaultStableHashKey
          ( stableHashEncodingWord64LE stableHashEncodingVersion
              <> stableHashEncodingWord8 0x6d
              <> stableHashEncodingWord64LE 42
          )
          @?= stableHashBuilder (Builder.word8 0x6d <> Builder.word64LE 42),
      testCase "text utf8 writer matches encodeUtf8 bytes" $
        let textValue =
              Text.pack "moonλ🌙"
         in stableHashEncodingDigest
              defaultStableHashKey
              (stableHashEncodingWord64LE stableHashEncodingVersion <> stableHashEncodingTextUtf8 textValue)
              @?= stableHashBuilder (Builder.byteString (TextEncoding.encodeUtf8 textValue)),
      testProperty "word64 decimal writer matches materialized decimal bytes" $
        \(wordValue :: Word64) ->
          stableHashEncodingDigest
            defaultStableHashKey
            (stableHashEncodingWord64LE stableHashEncodingVersion <> stableHashEncodingWord64Dec wordValue)
            === stableHashBuilder (Builder.word64Dec wordValue),
      testCase "byte-string block reader preserves partial-state overlaps" $
        let sampleBytes byteLength =
              ByteString.pack (take byteLength (cycle [0 .. 251]))
            prefixes =
              fmap sampleBytes [0 .. 15]
            payloads =
              fmap sampleBytes [0 .. 40]
            assertOverlap prefix payload =
              sipHashFinalize
                ( sipHashUpdateByteString
                    (sipHashUpdateByteString (sipHashInit defaultStableHashKey) prefix)
                    payload
                )
                @?= sipHashDigest defaultStableHashKey (prefix <> payload)
         in traverse_ (\prefix -> traverse_ (assertOverlap prefix) payloads) prefixes,
      testCase "sipHash24 reacts to key changes" $
        assertBool
          "different keys should perturb the digest"
          (sipHash24 17 23 (BS8.pack "meridian") /= sipHash24 17 24 (BS8.pack "meridian")),
      testProperty "framed fold is byte-for-byte identical to materialized one-shot sipHash" $
        \chunkWordLists ->
          let chunks = map ByteString.pack (chunkWordLists :: [[Word8]])
           in unStableHashDigest (stableHashByteStrings chunks)
                === sipHashDigest defaultStableHashKey (referenceFramedBytes chunks),
      testProperty "encoding chunks preserve byte-string framing" $
        \chunkWordLists ->
          let chunks = map ByteString.pack (chunkWordLists :: [[Word8]])
           in stableHashEncodingChunks stableHashEncodingByteString chunks
                === stableHashByteStrings chunks
    ]

-- The exact byte preimage 'stableHashByteStrings' streams into SipHash: version
-- word, compact chunk count, then each compact length-prefixed chunk. Hashing
-- this in one shot with @memory@'s 'sipHashDigest' proves the streamed fold
-- matches the authoritative framed byte sequence for every corpus.
referenceFramedBytes :: [ByteString] -> ByteString
referenceFramedBytes chunks =
  LazyByteString.toStrict
    ( Builder.toLazyByteString
        ( Builder.word64LE stableHashEncodingVersion
            <> compactWord64Builder (fromIntegral (length chunks))
            <> foldMap encodeChunk chunks
        )
    )
  where
    encodeChunk chunk =
      compactWord64Builder (fromIntegral (ByteString.length chunk))
        <> Builder.byteString chunk

compactWord64Builder :: Word64 -> Builder.Builder
compactWord64Builder wordValue =
  let byteValue =
        fromIntegral (wordValue `rem` 128)
      remainingValue =
        wordValue `quot` 128
   in if remainingValue == 0
        then Builder.word8 byteValue
        else Builder.word8 (byteValue + 128) <> compactWord64Builder remainingValue