diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83501c9..8679ed0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,13 +10,13 @@ permissions: contents: read jobs: - build: + test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] - ghc: ["9.8", "9.6"] + ghc: ['9.12', '9.10', '9.8', '9.6', '9.4', '9.2', '9.0'] steps: - name: Check out repository diff --git a/CHANGELOG.md b/CHANGELOG.md index 3539f13..6f565e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,4 +8,5 @@ and this project adheres to the ## Unreleased -## 0.1.0.0 - YYYY-MM-DD +## 0.1.0.0 - 2025-01-15 +- First release of `clockwork-base32` library. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..af63bff --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,13 @@ +## Contributing as a Developer + +- When creating a bug report: Please follow the template and provide detailed information. +- When fixing a feature: Create a Pull Request (PR) with accompanying test code. +- When adding a feature: First, propose the feature in an Issue. + +## Contributing Outside of Coding + +The following actions help boost my motivation: + +- Giving a GitHub Star +- Promoting the application +- Becoming a GitHub Sponsor diff --git a/README.md b/README.md index ff8727b..91850bd 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,33 @@ +[![Test](https://github.com/nao1215/clockwork-base32/actions/workflows/test.yml/badge.svg)](https://github.com/nao1215/clockwork-base32/actions/workflows/test.yml) + # clockwork-base32 Clockwork Base32 is a simple variant of Base32 inspired by Crockford's Base32. See [Clockwork Base32 Specification](https://gist.github.com/szktty/228f85794e4187882a77734c89c384a8). +## Supported Haskell Versions & OS +- GHC 9.0 or later +- Linux, macOS, Windows + ## Usage ```haskell --- TODO: Write usage examples here +module Main where + +import ClockworkBase32 (encode, decode) + +main :: IO () +main = do + -- encode example + let originalString = "foobar" + putStrLn $ "Original string: " ++ originalString + let encodedString = encode originalString + putStrLn $ "Encoded (Base32): " ++ encodedString + + -- decode example + let decodedResult = decode encodedString + case decodedResult of + Right decodedString -> putStrLn $ "Decoded string: " ++ decodedString + Left err -> putStrLn $ "Error decoding: " ++ err ``` ## Other Implementations @@ -27,3 +49,20 @@ Clockwork Base32 is a simple variant of Base32 inspired by Crockford's Base32. S - Rust: [hnakamur/rs-clockwork-base32](https://github.com/hnakamur/rs-clockwork-base32) - Go: [shogo82148/go-clockwork-base32](https://github.com/shogo82148/go-clockwork-base32) - TypeScript: [niyari/base32-ts](https://github.com/niyari/base32-ts) + + +## Contributing + +First off, thanks for taking the time to contribute! ❤️ See [CONTRIBUTING.md](./CONTRIBUTING.md) for more information. +Contributions are not only related to development. For example, GitHub Star motivates me to develop! + +## Contact + +If you would like to send comments such as "find a bug" or "request for additional features" to the developer, please use one of the following contacts. + +- [GitHub Issue](https://github.com/nao1215/clockwork-base32/issues) + + +## LICENSE + +[MIT License](./LICENSE) diff --git a/clockwork-base32.cabal b/clockwork-base32.cabal index c86ca48..949a3d6 100644 --- a/clockwork-base32.cabal +++ b/clockwork-base32.cabal @@ -10,7 +10,7 @@ license-file: LICENSE author: Naohiro CHIKAMATSU maintainer: n.chika156@gmail.com copyright: 2025 Naohiro CHIKAMATSU -category: Web +category: Base32 build-type: Simple extra-source-files: README.md CHANGELOG.md diff --git a/src/ClockworkBase32.hs b/src/ClockworkBase32.hs index a20bb22..ff459c3 100644 --- a/src/ClockworkBase32.hs +++ b/src/ClockworkBase32.hs @@ -1,8 +1,88 @@ module ClockworkBase32 - ( maxInList + ( encode, + decode ) where --- returns the maximum element in a list, if it exists -maxInList :: (Ord a) => [a] -> Maybe a -maxInList [] = Nothing -maxInList xs = Just (maximum xs) +import Data.Bits (shiftL, shiftR, (.&.), (.|.)) +import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as BSC +import Data.Word (Word8) + +-- clockwork base32 symbols +base32Symbols :: String +base32Symbols = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +-- | Encode a string into Clockwork Base32 format. +-- This function encodes a given input string into the Clockwork Base32 encoding +-- by processing it in chunks of 5 bytes. The input is converted into a sequence +-- of Base32 symbols using the Clockwork Base32 alphabet. +-- +-- For example: +-- encode "foobar" => "CSQPYRK1E8" +encode :: String -> String +encode input = processChunks inputBytes + where + inputBytes = BS.unpack $ BSC.pack input + processChunks [] = "" + processChunks bytes = encodeChunk (take 5 bytes) ++ processChunks (drop 5 bytes) + +-- | Encode a chunk of 5 bytes into Base32 symbols. +-- This function takes a chunk of 1 to 5 bytes and converts it into the corresponding +-- Base32 symbols. If the chunk is smaller than 5 bytes, the number of output symbols +-- will be adjusted accordingly. +-- +-- For example: +-- encodeChunk [102] => "CR" +encodeChunk :: [Word8] -> String +encodeChunk chunk = take outputLength $ map (base32Symbols !!) indices + where + -- 5 bytes -> 40 bits + val :: Integer + val = foldl (\acc (b, i) -> acc .|. (fromIntegral b `shiftL` (32 - 8 * i))) 0 (zip chunk [0..]) + -- split 40 bits into 8 5-bit indices + indices = [fromIntegral ((val `shiftR` (35 - 5 * i)) .&. 0x1F) | i <- [0..7]] + -- determine the number of symbols to output based on the input size + outputLength = case length chunk of + 1 -> 2 + 2 -> 4 + 3 -> 5 + 4 -> 7 + 5 -> 8 + _ -> 0 + +-- | Decode a Clockwork Base32 encoded string into the original string. +decode :: String -> Either String String +decode input = case mapM decodeChar input of + Left err -> Left err + Right symbols -> processChunks symbols [] + where + -- convert a Base32 symbol into a value + decodeChar :: Char -> Either String Word8 + decodeChar c = case lookup c decodeMap of + Just val -> Right val + Nothing -> Left ("Invalid character: " ++ [c]) + + -- process the input symbols in chunks of 8 characters + processChunks :: [Word8] -> [Word8] -> Either String String + processChunks [] acc = Right (BSC.unpack (BS.pack (reverse acc))) + processChunks symbols acc = case decodeChunk (take 8 symbols) of + [] -> processChunks (drop 8 symbols) acc + chunk -> processChunks (drop 8 symbols) (reverse chunk ++ acc) + + -- decode a chunk of 8 Base32 symbols into 5 bytes + decodeChunk :: [Word8] -> [Word8] + decodeChunk chunk = [fromIntegral byte | byte <- [v1, v2, v3, v4, v5], byte /= 0] + where + -- 8 symbols -> 40 bits + val :: Integer + val = foldl (\acc (b, i) -> acc .|. (fromIntegral b `shiftL` (35 - 5 * i))) 0 (zip chunk [0..]) + -- split 40 bits into 5 8-bit values + v1 = (val `shiftR` 32) .&. 0xFF + v2 = (val `shiftR` 24) .&. 0xFF + v3 = (val `shiftR` 16) .&. 0xFF + v4 = (val `shiftR` 8) .&. 0xFF + v5 = val .&. 0xFF + + -- create a map for decoding Base32 symbols + decodeMap :: [(Char, Word8)] + decodeMap = zip base32Symbols [0..] diff --git a/test/Spec.hs b/test/Spec.hs index b3a0d81..c5a11ca 100644 --- a/test/Spec.hs +++ b/test/Spec.hs @@ -1,14 +1,80 @@ module Main (main) where -import ClockworkBase32 (maxInList) +import ClockworkBase32 (decode, encode) import Test.Hspec - main :: IO () main = hspec $ do - describe "maxInList" $ do - it "returns Nothing for an empty list" $ do - maxInList ([] :: [Int]) `shouldBe` Nothing + -- testcase from + -- https://github.com/szktty/go-clockwork-base32/blob/c2cac4daa7ad2045089b943b377b12ac57e3254e/base32_test.go#L36-L44 + -- https://github.com/shiguredo/erlang-base32/blob/0cc88a702ce1d8ca345e516a05a9a85f7f23a718/test/base32_clockwork_test.erl#L7-L18 + describe "encode" $ do + it "returns '' when input value is ''" $ do + encode "" `shouldBe` "" + + it "returns 'CR' when input value is 'f'" $ do + encode "f" `shouldBe` "CR" + + it "returns 'CSQG' when input value is 'fo'" $ do + encode "fo" `shouldBe` "CSQG" + + it "returns 'CSQPY' when input value is 'foo'" $ do + encode "foo" `shouldBe` "CSQPY" + + it "returns 'CSQPYRG' when input value is 'foob'" $ do + encode "foob" `shouldBe` "CSQPYRG" + + it "returns 'CSQPYRK1' when input value is 'fooba'" $ do + encode "fooba" `shouldBe` "CSQPYRK1" + + it "returns 'CSQPYRK1E8' when input value is 'foobar'" $ do + encode "foobar" `shouldBe` "CSQPYRK1E8" + + it "returns '91JPRV3F5GG7EVVJDHJ22' when input value is 'Hello, world!'" $ do + encode "Hello, world!" `shouldBe` "91JPRV3F5GG7EVVJDHJ22" + + it "returns 'AHM6A83HENMP6TS0C9S6YXVE41K6YY10D9TPTW3K41QQCSBJ41T6GS90DHGQMY90CHQPEBG' when input value is 'The quick brown fox jumps over the lazy dog.'" $ do + encode "The quick brown fox jumps over the lazy dog." `shouldBe` "AHM6A83HENMP6TS0C9S6YXVE41K6YY10D9TPTW3K41QQCSBJ41T6GS90DHGQMY90CHQPEBG" + + it "returns '07EKWRQY2N7DEAVD5MJ3JX36KM' when input value is '\x01\xdd\x3e\x62\xfe\x15\x4e\xd7\x2b\x6d\x2d\x24\x39\x74\x66\x9d'" $ do + encode "\x01\xdd\x3e\x62\xfe\x15\x4e\xd7\x2b\x6d\x2d\x24\x39\x74\x66\x9d" `shouldBe` "07EKWRQY2N7DEAVD5MJ3JX36KM" + + it "returns 'AXQQEB10D5T20WK5C5P6RY90EXQQ4TVK44' when input value is 'Wow, it really works!'" $ do + encode "Wow, it really works!" `shouldBe` "AXQQEB10D5T20WK5C5P6RY90EXQQ4TVK44" + + describe "decode" $ do + it "returns Right '' when input value is ''" $ do + decode "" `shouldBe` Right "" + + it "returns Right 'f' when input value is 'CR'" $ do + decode "CR" `shouldBe` Right "f" + + it "returns Right 'fo' when input value is 'CSQG'" $ do + decode "CSQG" `shouldBe` Right "fo" + + it "returns Right 'foo' when input value is 'CSQPY'" $ do + decode "CSQPY" `shouldBe` Right "foo" + + it "returns Right 'foob' when input value is 'CSQPYRG'" $ do + decode "CSQPYRG" `shouldBe` Right "foob" + + it "returns Right 'fooba' when input value is 'CSQPYRK1'" $ do + decode "CSQPYRK1" `shouldBe` Right "fooba" + + it "returns Right 'foobar' when input value is 'CSQPYRK1E8'" $ do + decode "CSQPYRK1E8" `shouldBe` Right "foobar" + + it "returns Right 'Hello, world!' when input value is '91JPRV3F5GG7EVVJDHJ22'" $ do + decode "91JPRV3F5GG7EVVJDHJ22" `shouldBe` Right "Hello, world!" + + it "returns Right 'The quick brown fox jumps over the lazy dog.' when input value is 'AHM6A83HENMP6TS0C9S6YXVE41K6YY10D9TPTW3K41QQCSBJ41T6GS90DHGQMY90CHQPEBG'" $ do + decode "AHM6A83HENMP6TS0C9S6YXVE41K6YY10D9TPTW3K41QQCSBJ41T6GS90DHGQMY90CHQPEBG" `shouldBe` Right "The quick brown fox jumps over the lazy dog." + + it "returns Right '\x01\xdd\x3e\x62\xfe\x15\x4e\xd7\x2b\x6d\x2d\x24\x39\x74\x66\x9d' when input value is '07EKWRQY2N7DEAVD5MJ3JX36KM'" $ do + decode "07EKWRQY2N7DEAVD5MJ3JX36KM" `shouldBe` Right "\x01\xdd\x3e\x62\xfe\x15\x4e\xd7\x2b\x6d\x2d\x24\x39\x74\x66\x9d" + + it "returns Right 'Wow, it really works!' when input value is 'AXQQEB10D5T20WK5C5P6RY90EXQQ4TVK44'" $ do + decode "AXQQEB10D5T20WK5C5P6RY90EXQQ4TVK44" `shouldBe` Right "Wow, it really works!" - it "returns the maximum element for a non-empty list" $ do - maxInList ([1, 2, 3] :: [Int]) `shouldBe` Just 3 + it "returns Left 'Invalid character: ~' when input value is '~'" $ do + decode "~" `shouldBe` Left "Invalid character: ~"