Snapshot.hs (4919B)
1 {-# LANGUAGE BangPatterns #-} 2 {-# LANGUAGE RankNTypes #-} 3 -- Internal authenticated transport for indexed codecs, not a store service. 4 -- No paths, source trees or application publication policy are interpreted here. 5 module ContentStore.Snapshot 6 ( Snapshot, snapshotLength, readSnapshot 7 , SnapshotError(..), withAuthenticatedSnapshot, withAuthenticatedSnapshotUsing 8 ) where 9 10 import ContentStore.Object (Domain(..), ObjectHash, hashToText, validateObjectHash) 11 import Control.Concurrent.MVar 12 import Control.Exception 13 import Control.Monad (unless, when) 14 import Crypto.Hash (Context, SHA256, hashInit, hashUpdate, hashFinalize) 15 import qualified Data.ByteString as BS 16 import Data.Text.Encoding (encodeUtf8) 17 import System.Directory (removeFile) 18 import System.IO 19 20 -- The constructor and writable handle never escape this module. A Snapshot is 21 -- valid only during the callback; its read operation serializes seek/read pairs. 22 -- This is not confinement against other code with the process's OS authority. 23 data Snapshot = Snapshot !Integer !(MVar Handle) 24 25 snapshotLength :: Snapshot -> Integer 26 snapshotLength (Snapshot size _) = size 27 28 data SnapshotError = InvalidSnapshotBound | InvalidSnapshotDigest 29 | SnapshotTruncated | SnapshotReaderExceededBound | SnapshotDigestMismatch 30 | InvalidSnapshotRange 31 deriving (Eq, Show) 32 instance Exception SnapshotError 33 34 chunkSize :: Int 35 chunkSize = 4096 36 37 -- Both limits are checked before allocating a file or consuming input. The 38 -- caller supplies an EXACT frame length and a policy limit: neither an EOF probe 39 -- nor any byte of the following frame is requested. A reader must return between 40 -- zero and the requested number of bytes; zero before the boundary is truncation. 41 -- 42 -- Disk: one unlinked file, at most size payload bytes (+ filesystem bookkeeping). 43 -- Transfer memory: one bounded chunk and hash context, independent of size. 44 -- Authentication finishes before invoking the decoder callback, against the same 45 -- private bytes it will read, never a second pass over the original source. 46 -- No fsync is necessary: this is a temporary snapshot, not durable publication. 47 withAuthenticatedSnapshot :: FilePath -> Integer -> Integer -> Domain -> ObjectHash 48 -> (Int -> IO BS.ByteString) -> (Snapshot -> IO a) -> IO a 49 withAuthenticatedSnapshot = withAuthenticatedSnapshotUsing (\allocate use -> bracket allocate hClose use) 50 51 -- Native structured IO supplies registry-backed lifetime management here, so a 52 -- terminal fault/cancellation leaves cleanup to lexical settlement (including its 53 -- diagnostic precedence), rather than allowing bracket cleanup to mask a fault. 54 withAuthenticatedSnapshotUsing :: (forall a. IO Handle -> (Handle -> IO a) -> IO a) 55 -> FilePath -> Integer -> Integer -> Domain -> ObjectHash 56 -> (Int -> IO BS.ByteString) -> (Snapshot -> IO b) -> IO b 57 withAuthenticatedSnapshotUsing lifetime directory limit size (Domain domain) expected receive consume = do 58 when (limit < 0 || size < 0 || size > limit || limit > 2^(63 :: Int)-1) $ 59 throwIO InvalidSnapshotBound 60 case validateObjectHash expected of 61 Left _ -> throwIO InvalidSnapshotDigest 62 Right () -> pure () 63 lifetime acquire $ \handle -> do 64 let initial = hashUpdate (hashInit :: Context SHA256) (encodeUtf8 domain <> BS.singleton 0) 65 copy !remaining !context 66 | remaining == 0 = pure context 67 | otherwise = do 68 let wanted = fromInteger (min remaining (toInteger chunkSize)) 69 bytes <- receive wanted 70 let count = BS.length bytes 71 when (count > wanted) (throwIO SnapshotReaderExceededBound) 72 when (count == 0) (throwIO SnapshotTruncated) 73 BS.hPut handle bytes 74 next <- evaluate (hashUpdate context bytes) 75 copy (remaining - toInteger count) next 76 context <- copy size initial 77 unless (hashToText (hashFinalize context) == expected) (throwIO SnapshotDigestMismatch) 78 hFlush handle 79 mutex <- newMVar handle 80 consume (Snapshot size mutex) 81 where 82 acquire = mask_ $ do 83 (path, handle) <- openBinaryTempFile directory ".tricu-snapshot-" 84 -- Unlink before reading any untrusted input. No named scratch artifact is 85 -- retained after successful acquisition, including on process termination. 86 (removeFile path >> hSetBuffering handle NoBuffering >> pure handle) 87 `onException` hClose handle 88 89 -- Bounded positional reads from the authenticated snapshot. The supplied offset 90 -- is absolute; EOF is sticky by length, not inferred from the public input. 91 readSnapshot :: Snapshot -> Integer -> Int -> IO BS.ByteString 92 readSnapshot (Snapshot size mutex) offset count = do 93 when (offset < 0 || offset > size || count < 0 || count > chunkSize) $ 94 throwIO InvalidSnapshotRange 95 withMVar mutex $ \handle -> do 96 hSeek handle AbsoluteSeek offset 97 BS.hGet handle (fromInteger (min (toInteger count) (size - offset)))