diff --git a/.github/workflows/release-fork.yml b/.github/workflows/release-fork.yml new file mode 100644 index 000000000..81865b4b8 --- /dev/null +++ b/.github/workflows/release-fork.yml @@ -0,0 +1,95 @@ +name: "Build & Release" + +on: + workflow_dispatch: + inputs: + version: + description: 'Version name (e.g., 2.3.0-custom)' + required: true + default: 'dev' + push: + branches: + - main + tags: + - "v*" + +jobs: + build: + name: Build Linux (x86_64) + timeout-minutes: 30 + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v21 + + - name: Configure Cachix + uses: cachix/cachix-action@v16 + with: + name: trailofbits + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Set version + id: version + run: | + if [[ -n "${{ github.event.inputs.version }}" ]]; then + echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + elif [[ "${{ github.ref }}" =~ ^refs/tags/v.* ]]; then + echo "version=$(echo "${{ github.ref }}" | sed 's#^refs/tags/v##')" >> "$GITHUB_OUTPUT" + else + echo "version=HEAD-$(echo "${{ github.sha }}" | cut -c1-7)" >> "$GITHUB_OUTPUT" + fi + + - name: Build redistributable echidna + run: | + nix build ".#packages.x86_64-linux.echidna-redistributable" --out-link redistributable + tar -czf "echidna-${{ steps.version.outputs.version }}-x86_64-linux.tar.gz" -C ./redistributable/bin/ echidna + + - name: Upload artifact + uses: actions/upload-artifact@v5 + with: + name: echidna-x86_64-linux + path: echidna-${{ steps.version.outputs.version }}-x86_64-linux.tar.gz + + release: + name: Create release + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download artifacts + uses: actions/download-artifact@v6 + with: + pattern: echidna-* + merge-multiple: true + + - name: List artifacts + run: ls -la echidna-*.tar.gz + + - name: Create/Update rolling release (main branch) + if: github.ref == 'refs/heads/main' + uses: softprops/action-gh-release@v2 + with: + tag_name: latest + name: "Echidna Latest (main)" + body: | + Rolling release from main branch. + Version: ${{ needs.build.outputs.version }} + Commit: ${{ github.sha }} + prerelease: true + files: ./echidna-*.tar.gz + + - name: Create tagged release + if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' + uses: softprops/action-gh-release@v2 + with: + draft: true + name: "Echidna ${{ needs.build.outputs.version }}" + tag_name: ${{ github.ref_name || format('v{0}', github.event.inputs.version) }} + files: ./echidna-*.tar.gz diff --git a/lib/Echidna.hs b/lib/Echidna.hs index d0dfee6dd..bdaeb76e5 100644 --- a/lib/Echidna.hs +++ b/lib/Echidna.hs @@ -131,7 +131,8 @@ mkEnv cfg buildOutput tests world slitherInfo = do contractNameCache <- newIORef mempty -- TODO put in real path let dapp = dappInfo "/" buildOutput - pure $ Env { cfg, dapp, codehashMap, fetchSession, contractNameCache + sourceCache = buildOutput.sources + pure $ Env { cfg, dapp, sourceCache, codehashMap, fetchSession, contractNameCache , chainId, eventQueue, coverageRefInit, coverageRefRuntime, corpusRef, testRefs, world , slitherInfo } diff --git a/lib/Echidna/Output/Source.hs b/lib/Echidna/Output/Source.hs index 66f757bcf..73a1a3959 100644 --- a/lib/Echidna/Output/Source.hs +++ b/lib/Echidna/Output/Source.hs @@ -1,7 +1,11 @@ {-# LANGUAGE ViewPatterns #-} {-# LANGUAGE TemplateHaskell #-} -module Echidna.Output.Source where +module Echidna.Output.Source ( + saveCoverages, + saveLcovHook, + checkAssertionsCoverage +) where import Control.Monad (unless) import Data.ByteString qualified as BS @@ -90,6 +94,24 @@ coverageFileExtension Lcov = ".lcov" coverageFileExtension Html = ".html" coverageFileExtension Txt = ".txt" +-- | Save only LCOV coverage triggered by HTTP hook, with timestamp in filename +saveLcovHook + :: Env + -> FilePath + -> SourceCache + -> [SolcContract] + -> IO FilePath +saveLcovHook env d sc cs = do + coverage <- mergeCoverageMaps env.dapp env.coverageRefInit env.coverageRefRuntime + currentTime <- getCurrentTime + let timestamp = formatTime defaultTimeLocale "%Y%m%d_%H%M%S" currentTime + fn = d "hook_" <> timestamp <> ".lcov" + excludePatterns = env.cfg.campaignConf.coverageExcludes + cc = ppCoveredCode Lcov sc cs coverage Nothing (T.pack timestamp) excludePatterns + createDirectoryIfMissing True d + writeFile fn cc + pure fn + -- | Pretty-print the covered code ppCoveredCode :: CoverageFileType -> SourceCache -> [SolcContract] -> FrozenCoverageMap -> Maybe Text -> Text -> [Text] -> Text ppCoveredCode fileType sc cs s projectName timestamp excludePatterns diff --git a/lib/Echidna/Server.hs b/lib/Echidna/Server.hs index 8524f472f..2c564ea67 100644 --- a/lib/Echidna/Server.hs +++ b/lib/Echidna/Server.hs @@ -1,18 +1,29 @@ module Echidna.Server where import Control.Concurrent -import Control.Monad (when, void) +import Control.DeepSeq (force) +import Control.Monad (forever, when, void) import Data.Aeson import Data.Binary.Builder (fromLazyByteString) import Data.IORef +import Data.Map qualified as Map +import Data.Set qualified as Set import Data.Time (LocalTime) import Data.Word (Word16) +import Network.HTTP.Types (status200, status404) +import Network.Wai (Application, responseLBS, pathInfo, requestMethod) import Network.Wai.EventSource (ServerEvent(..), eventSourceAppIO) import Network.Wai.Handler.Warp (run) +import System.FilePath (()) +import UnliftIO.STM (atomically, newBroadcastTChanIO, writeTChan, dupTChan, readTChan) -import Echidna.Types.Config (Env(..)) +import EVM.Dapp (DappInfo(..)) + +import Echidna.Output.Corpus (loadTxs) +import Echidna.Output.Source (saveLcovHook) +import Echidna.Types.Campaign (CampaignConf(..)) +import Echidna.Types.Config (Env(..), EConfig(..)) import Echidna.Types.Worker -import Echidna.Worker() newtype SSE = SSE (LocalTime, CampaignEvent) @@ -40,36 +51,84 @@ runSSEServer :: MVar () -> Env -> Word16 -> Int -> IO () runSSEServer serverStopVar env port nworkers = do aliveRef <- newIORef nworkers sseChan <- dupChan env.eventQueue + broadcastChan <- newBroadcastTChanIO + + void . forkIO . forever $ do + event@(_, campaignEvent) <- readChan sseChan + let eventName = \case + WorkerEvent _ _ workerEvent -> + case workerEvent of + TestFalsified _ -> "test_falsified" + TestOptimized _ -> "test_optimized" + NewCoverage {} -> "new_coverage" + SymExecLog _ -> "sym_exec_log" + SymExecError _ -> "sym_exec_error" + TxSequenceReplayed {} -> "tx_sequence_replayed" + TxSequenceReplayFailed {} -> "tx_sequence_replay_failed" + WorkerStopped _ -> "worker_stopped" + Failure _err -> "failure" + ReproducerSaved _ -> "saved_reproducer" + + let serverEvent = ServerEvent + { eventName = Just (eventName campaignEvent) + , eventId = Nothing + , eventData = [ fromLazyByteString $ encode (SSE event) ] + } + + atomically $ writeTChan broadcastChan serverEvent + + case campaignEvent of + WorkerEvent _ _ (WorkerStopped _) -> do + aliveAfter <- atomicModifyIORef' aliveRef (\n -> (n-1, n-1)) + when (aliveAfter == 0) $ do + atomically $ writeTChan broadcastChan CloseEvent + putMVar serverStopVar () + _ -> pure () + + let sseApp _req respond = do + myChan <- atomically $ dupTChan broadcastChan + let src = atomically $ readTChan myChan + eventSourceAppIO src _req respond + + let app :: Application + app req respond = case (requestMethod req, pathInfo req) of + -- SSE endpoint + ("GET", ["events"]) -> sseApp req respond + + -- Dump LCOV coverage + ("POST", ["dump_lcov"]) -> do + case env.cfg.campaignConf.corpusDir of + Nothing -> + respond $ responseLBS status404 [("Content-Type", "application/json")] + "{\"error\":\"No corpus directory configured\"}" + Just dir -> do + let contracts = Map.elems env.dapp.solcByName + fn <- saveLcovHook env dir env.sourceCache contracts + respond $ responseLBS status200 [("Content-Type", "application/json")] + (encode $ object ["file" .= fn]) + + -- Reload corpus from disk + ("POST", ["reload_corpus"]) -> do + case env.cfg.campaignConf.corpusDir of + Nothing -> + respond $ responseLBS status404 [("Content-Type", "application/json")] + "{\"error\":\"No corpus directory configured\"}" + Just dir -> do + -- Load transactions from reproducers and coverage directories + ctxs1 <- loadTxs (dir "reproducers") + ctxs2 <- loadTxs (dir "coverage") + let allTxs = ctxs1 ++ ctxs2 + -- Add to corpus (with index based on current corpus size) + loaded <- atomicModifyIORef' env.corpusRef $ \corpus -> + let newEntries = Set.fromList $ zip [Set.size corpus + 1..] (map snd allTxs) + !corpus' = force $ Set.union corpus newEntries + in (corpus', length allTxs) + respond $ responseLBS status200 [("Content-Type", "application/json")] + (encode $ object ["loaded" .= loaded, "message" .= ("Loaded " ++ show loaded ++ " transaction sequences" :: String)]) - let sseListener = do - aliveNow <- readIORef aliveRef - if aliveNow == 0 then - pure CloseEvent - else do - event@(_, campaignEvent) <- readChan sseChan - let eventName = \case - WorkerEvent _ _ workerEvent -> - case workerEvent of - TestFalsified _ -> "test_falsified" - TestOptimized _ -> "test_optimized" - NewCoverage {} -> "new_coverage" - SymExecLog _ -> "sym_exec_log" - SymExecError _ -> "sym_exec_error" - TxSequenceReplayed {} -> "tx_sequence_replayed" - TxSequenceReplayFailed {} -> "tx_sequence_replay_failed" - WorkerStopped _ -> "worker_stopped" - Failure _err -> "failure" - ReproducerSaved _ -> "saved_reproducer" - case campaignEvent of - WorkerEvent _ _ (WorkerStopped _) -> do - aliveAfter <- atomicModifyIORef' aliveRef (\n -> (n-1, n-1)) - when (aliveAfter == 0) $ putMVar serverStopVar () - _ -> pure () - pure $ ServerEvent - { eventName = Just (eventName campaignEvent) - , eventId = Nothing - , eventData = [ fromLazyByteString $ encode (SSE event) ] - } + -- Unknown endpoint + _ -> respond $ responseLBS status404 [("Content-Type", "application/json")] + "{\"error\":\"Not found\"}" void . forkIO $ do - run (fromIntegral port) $ eventSourceAppIO sseListener + run (fromIntegral port) app diff --git a/lib/Echidna/Types/Config.hs b/lib/Echidna/Types/Config.hs index 43e17665f..1b9a5b4c5 100644 --- a/lib/Echidna/Types/Config.hs +++ b/lib/Echidna/Types/Config.hs @@ -10,6 +10,7 @@ import Data.Word (Word64) import EVM.Dapp (DappInfo) import EVM.Fetch qualified as Fetch +import EVM.Solidity (SourceCache) import EVM.Types (Addr, W256) import Echidna.SourceAnalysis.Slither (SlitherInfo) @@ -69,6 +70,7 @@ data EConfigWithUsage = EConfigWithUsage data Env = Env { cfg :: EConfig , dapp :: DappInfo + , sourceCache :: SourceCache -- | Shared between all workers. Events are fairly rare so contention is -- minimal. diff --git a/package.yaml b/package.yaml index 6e519db4b..6882105cb 100644 --- a/package.yaml +++ b/package.yaml @@ -76,8 +76,10 @@ library: - vector - vty - vty-crossplatform + - wai - wai-extra - warp + - http-types - wreq - word-wrap - xml-conduit diff --git a/tests/solidity/basic/alex-sequence.sol b/tests/solidity/basic/alex-sequence.sol new file mode 100644 index 000000000..a61352117 --- /dev/null +++ b/tests/solidity/basic/alex-sequence.sol @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract AlexSequence { + uint256 public state = 0; + + function echidna_opt_state() public view returns (int256) { + return int256(state); + } + + function step0() public { + require(state == 0); + state = 1; + } + function step1() public { + require(state == 1); + state = 2; + } + function step2() public { + require(state == 2); + state = 3; + } + function step3() public { + require(state == 3); + state = 4; + } + function step4() public { + require(state == 4); + state = 5; + } + function step5() public { + require(state == 5); + state = 6; + } + function step6() public { + require(state == 6); + state = 7; + } + function step7() public { + require(state == 7); + state = 8; + } + function step8() public { + require(state == 8); + state = 9; + } + function step9() public { + require(state == 9); + state = 10; + } + function step10() public { + require(state == 10); + state = 11; + } + function step11() public { + require(state == 11); + state = 12; + } + function step12() public { + require(state == 12); + state = 13; + } + function step13() public { + require(state == 13); + state = 14; + } + function step14() public { + require(state == 14); + state = 15; + } + function step15() public { + require(state == 15); + state = 16; + } + function step16() public { + require(state == 16); + state = 17; + } + function step17() public { + require(state == 17); + state = 18; + } + function step18() public { + require(state == 18); + state = 19; + } + function step19() public { + require(state == 19); + state = 20; + } + function step20() public { + require(state == 20); + state = 21; + } + function step21() public { + require(state == 21); + state = 22; + } + function step22() public { + require(state == 22); + state = 23; + } + function step23() public { + require(state == 23); + state = 24; + } + function step24() public { + require(state == 24); + state = 25; + } + function step25() public { + require(state == 25); + state = 26; + } + function step26() public { + require(state == 26); + state = 27; + } + function step27() public { + require(state == 27); + state = 28; + } + function step28() public { + require(state == 28); + state = 29; + } + function step29() public { + require(state == 29); + state = 30; + } + function step30() public { + require(state == 30); + state = 31; + } + function step31() public { + require(state == 31); + state = 32; + } + function step32() public { + require(state == 32); + state = 33; + } + function step33() public { + require(state == 33); + state = 34; + } + function step34() public { + require(state == 34); + state = 35; + } + function step35() public { + require(state == 35); + state = 36; + } + function step36() public { + require(state == 36); + state = 37; + } + function step37() public { + require(state == 37); + state = 38; + } + function step38() public { + require(state == 38); + state = 39; + } + function step39() public { + require(state == 39); + state = 40; + } + function step40() public { + require(state == 40); + state = 41; + } + function step41() public { + require(state == 41); + state = 42; + } + function step42() public { + require(state == 42); + state = 43; + } + function step43() public { + require(state == 43); + state = 44; + } + function step44() public { + require(state == 44); + state = 45; + } + function step45() public { + require(state == 45); + state = 46; + } + function step46() public { + require(state == 46); + state = 47; + } + function step47() public { + require(state == 47); + state = 48; + } + function step48() public { + require(state == 48); + state = 49; + } + function step49() public { + require(state == 49); + state = 50; + } + function step50() public { + require(state == 50); + state = 51; + } + function step51() public { + require(state == 51); + state = 52; + } + function step52() public { + require(state == 52); + state = 53; + } + function step53() public { + require(state == 53); + state = 54; + } + function step54() public { + require(state == 54); + state = 55; + } + function step55() public { + require(state == 55); + state = 56; + } + function step56() public { + require(state == 56); + state = 57; + } + function step57() public { + require(state == 57); + state = 58; + } + function step58() public { + require(state == 58); + state = 59; + } + function step59() public { + require(state == 59); + state = 60; + } + function step60() public { + require(state == 60); + state = 61; + } + function step61() public { + require(state == 61); + state = 62; + } + function step62() public { + require(state == 62); + state = 63; + } + function step63() public { + require(state == 63); + state = 64; + } + function step64() public { + require(state == 64); + state = 65; + } + function step65() public { + require(state == 65); + state = 66; + } + function step66() public { + require(state == 66); + state = 67; + } + function step67() public { + require(state == 67); + state = 68; + } + function step68() public { + require(state == 68); + state = 69; + } + function step69() public { + require(state == 69); + state = 70; + } + function step70() public { + require(state == 70); + state = 71; + } + function step71() public { + require(state == 71); + state = 72; + } + function step72() public { + require(state == 72); + state = 73; + } + function step73() public { + require(state == 73); + state = 74; + } + function step74() public { + require(state == 74); + state = 75; + } + function step75() public { + require(state == 75); + state = 76; + } + function step76() public { + require(state == 76); + state = 77; + } + function step77() public { + require(state == 77); + state = 78; + } + function step78() public { + require(state == 78); + state = 79; + } + function step79() public { + require(state == 79); + state = 80; + } + function step80() public { + require(state == 80); + state = 81; + } + function step81() public { + require(state == 81); + state = 82; + } + function step82() public { + require(state == 82); + state = 83; + } + function step83() public { + require(state == 83); + state = 84; + } + function step84() public { + require(state == 84); + state = 85; + } + function step85() public { + require(state == 85); + state = 86; + } + function step86() public { + require(state == 86); + state = 87; + } + function step87() public { + require(state == 87); + state = 88; + } + function step88() public { + require(state == 88); + state = 89; + } + function step89() public { + require(state == 89); + state = 90; + } + function step90() public { + require(state == 90); + state = 91; + } + function step91() public { + require(state == 91); + state = 92; + } + function step92() public { + require(state == 92); + state = 93; + } + function step93() public { + require(state == 93); + state = 94; + } + function step94() public { + require(state == 94); + state = 95; + } + function step95() public { + require(state == 95); + state = 96; + } + function step96() public { + require(state == 96); + state = 97; + } + function step97() public { + require(state == 97); + state = 98; + } + function step98() public { + require(state == 98); + state = 99; + } + function step99() public { + require(state == 99); + state = 100; + } +} diff --git a/tests/solidity/basic/alex-sequence.yaml b/tests/solidity/basic/alex-sequence.yaml new file mode 100644 index 000000000..32d4d5fc9 --- /dev/null +++ b/tests/solidity/basic/alex-sequence.yaml @@ -0,0 +1,2 @@ +testMode: optimization +coverageFormats: ["txt","html","lcov"] \ No newline at end of file