summaryrefslogtreecommitdiff
path: root/source/Render/Html/Output.hs
diff options
context:
space:
mode:
Diffstat (limited to 'source/Render/Html/Output.hs')
-rw-r--r--source/Render/Html/Output.hs315
1 files changed, 109 insertions, 206 deletions
diff --git a/source/Render/Html/Output.hs b/source/Render/Html/Output.hs
index 0354806..a8b4d93 100644
--- a/source/Render/Html/Output.hs
+++ b/source/Render/Html/Output.hs
@@ -9,9 +9,9 @@
-- symlinks are rejected without following them; regular generated files may be
-- replaced. This policy prevents stable-tree escapes, not TOCTOU attacks.
module Render.Html.Output
- ( PreparedHtmlBundle
- , preparedHtmlBundle
- , preparedHtmlBundleDestinations
+ ( PreparedHtmlArtifact
+ , preparedHtmlArtifact
+ , preparedHtmlArtifactDestination
, HtmlRoutePlan
, htmlRoutePlanDestinations
, planHtmlRoutes
@@ -37,57 +37,34 @@ import Control.Monad (unless, when)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
-import Data.IORef
- ( IORef
- , atomicModifyIORef'
- , newIORef
- )
import Data.List qualified as List
import Data.Map.Strict qualified as Map
+import Data.Set qualified as Set
import Data.Text qualified as Text
import System.Directory qualified as Directory
import System.FilePath.Posix qualified as Posix
import System.Posix.Files qualified as PosixFiles
+-- | One lazily rendered artifact. The destination is available for complete
+-- preflight without demanding the strict bytes or a renderer failure.
data PreparedHtmlArtifact = PreparedHtmlArtifact
!SafeRelativePath
- !ByteString
-
--- | A complete, destination-unique bundle of strict bytes.
-newtype PreparedHtmlBundle = PreparedHtmlBundle
- [PreparedHtmlArtifact]
-
-preparedHtmlBundle
- :: [(SafeRelativePath, ByteString)]
- -> Either HtmlOutputError PreparedHtmlBundle
-preparedHtmlBundle artifacts =
- case artifacts of
- [] ->
- Left EmptyPreparedHtmlBundle
- _ ->
- case duplicateDestinations
- (fst <$> artifacts) of
- duplicate : _ ->
- Left
- (DuplicatePreparedHtmlDestination
- duplicate)
- [] ->
- Right
- (PreparedHtmlBundle
- [ PreparedHtmlArtifact destination bytes
- | (destination, bytes) <-
- Map.toAscList
- (Map.fromList artifacts)
- ])
-
-preparedHtmlBundleDestinations
- :: PreparedHtmlBundle
- -> [SafeRelativePath]
-preparedHtmlBundleDestinations (PreparedHtmlBundle artifacts) =
- [ destination
- | PreparedHtmlArtifact destination _bytes <- artifacts
- ]
+ (Either Text ByteString)
+
+preparedHtmlArtifact
+ :: SafeRelativePath
+ -> Either Text ByteString
+ -> PreparedHtmlArtifact
+preparedHtmlArtifact =
+ PreparedHtmlArtifact
+
+preparedHtmlArtifactDestination
+ :: PreparedHtmlArtifact
+ -> SafeRelativePath
+preparedHtmlArtifactDestination
+ (PreparedHtmlArtifact destination _rendered) =
+ destination
-- Constructors and absolute paths stay private to this module.
@@ -106,10 +83,10 @@ newtype HtmlOutputPlan = HtmlOutputPlan
data PlannedHtmlArtifact = PlannedHtmlArtifact
!SafeRelativePath
!FilePath
- !ByteString
+ (Either Text ByteString)
data HtmlOutputError
- = EmptyPreparedHtmlBundle
+ = EmptyPreparedHtmlOutput
| DuplicatePreparedHtmlDestination !SafeRelativePath
| HtmlOutputRouteMismatch
![SafeRelativePath]
@@ -125,7 +102,7 @@ data HtmlOutputError
renderHtmlOutputError :: HtmlOutputError -> Text
renderHtmlOutputError = \case
- EmptyPreparedHtmlBundle ->
+ EmptyPreparedHtmlOutput ->
"HTML output contains no artifacts"
DuplicatePreparedHtmlDestination relative ->
"HTML output contains destination more than once: "
@@ -162,13 +139,13 @@ instance Exception HtmlOutputError
-- | Validate every destination without changing the filesystem.
planHtmlOutput
:: FilePath
- -> PreparedHtmlBundle
+ -> [PreparedHtmlArtifact]
-> IO (Either HtmlOutputError HtmlOutputPlan)
-planHtmlOutput outputRoot bundle = do
+planHtmlOutput outputRoot artifacts = do
routes <- planHtmlRoutes
outputRoot
- (preparedHtmlBundleDestinations bundle)
- pure (routes >>= (`planHtmlOutputAgainst` bundle))
+ (preparedHtmlArtifactDestination <$> artifacts)
+ pure (routes >>= (`planHtmlOutputAgainst` artifacts))
planHtmlRoutes
:: FilePath
@@ -177,7 +154,7 @@ planHtmlRoutes
planHtmlRoutes outputRoot destinations =
runExceptT do
when (null destinations)
- (throwE EmptyPreparedHtmlBundle)
+ (throwE EmptyPreparedHtmlOutput)
case duplicateDestinations destinations of
duplicate : _ ->
throwE
@@ -227,33 +204,41 @@ planHtmlRoutes outputRoot destinations =
planHtmlOutputAgainst
:: HtmlRoutePlan
- -> PreparedHtmlBundle
+ -> [PreparedHtmlArtifact]
-> Either HtmlOutputError HtmlOutputPlan
planHtmlOutputAgainst
(HtmlRoutePlan routes)
- (PreparedHtmlBundle artifacts)
- | plannedDestinations /= bundleDestinations =
+ artifacts
+ | null artifacts =
+ Left EmptyPreparedHtmlOutput
+ | duplicate : _ <- duplicateDestinations preparedDestinations =
+ Left (DuplicatePreparedHtmlDestination duplicate)
+ | Set.fromList plannedDestinations
+ /= Set.fromList preparedDestinations =
Left
(HtmlOutputRouteMismatch
plannedDestinations
- bundleDestinations)
- | otherwise =
- Right
- (HtmlOutputPlan
- [ PlannedHtmlArtifact
- relative
- destination
- bytes
- | ( (relative, destination)
- , PreparedHtmlArtifact _ bytes
- ) <- zip routes artifacts
- ])
+ preparedDestinations)
+ | otherwise = HtmlOutputPlan <$> traverse attach artifacts
where
plannedDestinations = fst <$> routes
- bundleDestinations =
- [ relative
- | PreparedHtmlArtifact relative _bytes <- artifacts
- ]
+ preparedDestinations =
+ preparedHtmlArtifactDestination <$> artifacts
+ routeDestinations = Map.fromList routes
+
+ attach (PreparedHtmlArtifact relative rendered) =
+ case Map.lookup relative routeDestinations of
+ Nothing ->
+ Left
+ (HtmlOutputRouteMismatch
+ plannedDestinations
+ preparedDestinations)
+ Just destination ->
+ Right
+ (PlannedHtmlArtifact
+ relative
+ destination
+ rendered)
duplicateDestinations
:: [SafeRelativePath]
@@ -293,127 +278,68 @@ renderHtmlPublicationError failure =
(quoteRelative <$> committed)
]
-data StagedHtmlArtifact = StagedHtmlArtifact
- !SafeRelativePath
- !FilePath
- !FilePath
-
--- | Publish a completely preflighted bundle.
---
--- All strict bytes are staged in temporary siblings before the first rename.
--- Each rename atomically replaces one directory entry. The bundle as a whole
--- is not atomic: a later failure reports the already committed destinations
--- and removes the remaining temporary files.
+-- | Render, stage, and atomically replace each completely preflighted artifact
+-- in the supplied source order. No later artifact is rendered or staged
+-- before the preceding destination has been replaced.
writeHtmlOutput
:: HtmlOutputPlan
-> IO (Either HtmlPublicationError ())
writeHtmlOutput (HtmlOutputPlan planned) =
- Exception.mask \restore -> do
- stagedRef <- newIORef []
- result <-
- restore
- (stageAndPublish stagedRef planned)
- `Exception.onException`
- cleanupStagedRef stagedRef
- cleanupStagedRef stagedRef
- pure result
-
-stageAndPublish
- :: IORef [StagedHtmlArtifact]
- -> [PlannedHtmlArtifact]
- -> IO (Either HtmlPublicationError ())
-stageAndPublish stagedRef planned = do
- stagedResult <- stageAll stagedRef planned
- case stagedResult of
- Left err ->
- pure (Left err)
- Right staged ->
- publishAll stagedRef [] staged
-
-stageAll
- :: IORef [StagedHtmlArtifact]
- -> [PlannedHtmlArtifact]
- -> IO
- (Either
- HtmlPublicationError
- [StagedHtmlArtifact])
-stageAll stagedRef =
- go []
- where
- go staged [] =
- pure (Right (reverse staged))
- go staged
- (artifact@(PlannedHtmlArtifact relative _destination _bytes)
- : remaining) = do
- result <- tryIOException (stageArtifact artifact)
- case result of
- Left err ->
- pure
- (Left
- (publicationError
- []
- relative
- err))
- Right stagedArtifact -> do
- atomicModifyIORef' stagedRef
- \current ->
- (stagedArtifact : current, ())
- go
- (stagedArtifact : staged)
- remaining
-
-stageArtifact
- :: PlannedHtmlArtifact
- -> IO StagedHtmlArtifact
-stageArtifact
- (PlannedHtmlArtifact relative destination bytes) = do
- let directory = Posix.takeDirectory destination
- Directory.createDirectoryIfMissing True directory
- bracketOnError
- (openBinaryTempFileWithDefaultPermissions
- directory
- (Posix.takeFileName destination <> ".tmp"))
- cleanupTemporary
- \(temporary, handle) -> do
- ByteString.hPut handle bytes
- hFlush handle
- hClose handle
- pure
- (StagedHtmlArtifact
- relative
- destination
- temporary)
+ publishAll [] planned
publishAll
- :: IORef [StagedHtmlArtifact]
- -> [SafeRelativePath]
- -> [StagedHtmlArtifact]
+ :: [SafeRelativePath]
+ -> [PlannedHtmlArtifact]
-> IO (Either HtmlPublicationError ())
-publishAll _stagedRef _committed [] =
+publishAll _committed [] =
pure (Right ())
publishAll
- stagedRef
- committedReversed
- ( staged@(StagedHtmlArtifact relative destination temporary)
- : remaining
- ) = do
- result <-
- tryIOException
- (Directory.renameFile temporary destination)
- case result of
- Left err ->
- pure
- (Left
- (publicationError
- (reverse committedReversed)
- relative
- err))
- Right () -> do
- forgetStaged stagedRef staged
- publishAll
- stagedRef
- (relative : committedReversed)
- remaining
+ committedReversed
+ (PlannedHtmlArtifact relative destination rendered : remaining) =
+ case rendered of
+ Left failure ->
+ pure
+ (Left
+ (IncompleteHtmlPublication
+ { committedHtmlDestinations =
+ reverse committedReversed
+ , failedHtmlDestination = relative
+ , htmlPublicationFailure = failure
+ }))
+ Right bytes -> do
+ result <-
+ tryIOException
+ (stageAndReplace destination bytes)
+ case result of
+ Left err ->
+ pure
+ (Left
+ (publicationError
+ (reverse committedReversed)
+ relative
+ err))
+ Right () ->
+ publishAll
+ (relative : committedReversed)
+ remaining
+
+stageAndReplace
+ :: FilePath
+ -> ByteString
+ -> IO ()
+stageAndReplace destination bytes = do
+ let directory = Posix.takeDirectory destination
+ Directory.createDirectoryIfMissing True directory
+ bracketOnError
+ (openBinaryTempFileWithDefaultPermissions
+ directory
+ (Posix.takeFileName destination <> ".tmp"))
+ cleanupTemporary
+ \(temporary, handle) -> do
+ ByteString.hPut handle bytes
+ hFlush handle
+ hClose handle
+ Directory.renameFile temporary destination
publicationError
:: [SafeRelativePath]
@@ -428,29 +354,6 @@ publicationError committed failed err =
Text.pack (displayException err)
}
-forgetStaged
- :: IORef [StagedHtmlArtifact]
- -> StagedHtmlArtifact
- -> IO ()
-forgetStaged stagedRef (StagedHtmlArtifact _ _ temporary) =
- atomicModifyIORef' stagedRef
- \staged ->
- ( List.filter
- (\(StagedHtmlArtifact _ _ candidate) ->
- candidate /= temporary)
- staged
- , ()
- )
-
-cleanupStagedRef :: IORef [StagedHtmlArtifact] -> IO ()
-cleanupStagedRef stagedRef = do
- staged <- atomicModifyIORef' stagedRef (\current -> ([], current))
- traverse_ cleanupStagedPath staged
-
-cleanupStagedPath :: StagedHtmlArtifact -> IO ()
-cleanupStagedPath (StagedHtmlArtifact _ _ temporary) =
- void (tryIOError (Directory.removeFile temporary))
-
cleanupTemporary :: (FilePath, Handle) -> IO ()
cleanupTemporary (temporary, handle) = do
void (tryIOError (hClose handle))