1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
|
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | Confined filesystem authority for HTML output.
--
-- The output root is assumed to be user-owned and not concurrently changed by
-- a hostile actor between planning and writing. Existing parent symlinks are
-- accepted only when they resolve inside the canonical root. Final-target
-- 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
( PreparedHtmlArtifact
, preparedHtmlArtifact
, preparedHtmlArtifactDestination
, HtmlRoutePlan
, htmlRoutePlanDestinations
, planHtmlRoutes
, HtmlOutputPlan
, HtmlOutputError(..)
, renderHtmlOutputError
, planHtmlOutput
, planHtmlOutputAgainst
, HtmlPublicationError(..)
, renderHtmlPublicationError
, writeHtmlOutput
) where
import Base
import Felix.Source
( SafeRelativePath
, safeRelativePathFilePath
)
import Control.Exception (Exception, IOException, displayException)
import Control.Exception qualified as Exception
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.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
(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.
newtype HtmlRoutePlan = HtmlRoutePlan
[(SafeRelativePath, FilePath)]
htmlRoutePlanDestinations
:: HtmlRoutePlan
-> [(SafeRelativePath, FilePath)]
htmlRoutePlanDestinations (HtmlRoutePlan routes) =
routes
newtype HtmlOutputPlan = HtmlOutputPlan
[PlannedHtmlArtifact]
data PlannedHtmlArtifact = PlannedHtmlArtifact
!SafeRelativePath
!FilePath
(Either Text ByteString)
data HtmlOutputError
= EmptyPreparedHtmlOutput
| DuplicatePreparedHtmlDestination !SafeRelativePath
| HtmlOutputRouteMismatch
![SafeRelativePath]
![SafeRelativePath]
| EmptyHtmlOutputRoot
| HtmlOutputPathInspectionFailed !FilePath !Text
| HtmlOutputRootNotDirectory !FilePath
| HtmlOutputParentNotDirectory !FilePath
| HtmlOutputParentEscapesRoot !FilePath !FilePath
| HtmlOutputTargetIsSymbolicLink !FilePath
| HtmlOutputTargetNotRegularFile !FilePath
deriving stock (Show, Eq)
renderHtmlOutputError :: HtmlOutputError -> Text
renderHtmlOutputError = \case
EmptyPreparedHtmlOutput ->
"HTML output contains no artifacts"
DuplicatePreparedHtmlDestination relative ->
"HTML output contains destination more than once: "
<> quoteRelative relative
HtmlOutputRouteMismatch planned prepared ->
"prepared HTML destinations do not match the reserved routes; planned "
<> renderRelatives planned <> ", prepared " <> renderRelatives prepared
EmptyHtmlOutputRoot ->
"HTML output root is empty"
HtmlOutputPathInspectionFailed path reason ->
"could not inspect HTML output path " <> quotePath path <> ": " <> reason
HtmlOutputRootNotDirectory path ->
"HTML output root is not a directory: " <> quotePath path
HtmlOutputParentNotDirectory path ->
"HTML output parent is not a directory: " <> quotePath path
HtmlOutputParentEscapesRoot root parent ->
"HTML output parent " <> quotePath parent
<> " resolves outside root " <> quotePath root
HtmlOutputTargetIsSymbolicLink path ->
"HTML output target is a symbolic link: " <> quotePath path
HtmlOutputTargetNotRegularFile path ->
"HTML output target is not a regular file: " <> quotePath path
where
renderRelatives = Text.intercalate ", " . fmap quoteRelative
quoteRelative :: SafeRelativePath -> Text
quoteRelative = quotePath . safeRelativePathFilePath
quotePath :: FilePath -> Text
quotePath = Text.pack . show
instance Exception HtmlOutputError
-- | Validate every destination without changing the filesystem.
planHtmlOutput
:: FilePath
-> [PreparedHtmlArtifact]
-> IO (Either HtmlOutputError HtmlOutputPlan)
planHtmlOutput outputRoot artifacts = do
routes <- planHtmlRoutes
outputRoot
(preparedHtmlArtifactDestination <$> artifacts)
pure (routes >>= (`planHtmlOutputAgainst` artifacts))
planHtmlRoutes
:: FilePath
-> [SafeRelativePath]
-> IO (Either HtmlOutputError HtmlRoutePlan)
planHtmlRoutes outputRoot destinations =
runExceptT do
when (null destinations)
(throwE EmptyPreparedHtmlOutput)
case duplicateDestinations destinations of
duplicate : _ ->
throwE
(DuplicatePreparedHtmlDestination duplicate)
[] ->
pure ()
when (null outputRoot) (throwE EmptyHtmlOutputRoot)
absoluteRoot <-
inspectPath
outputRoot
(Directory.makeAbsolute outputRoot)
rootIsLink <- inspectSymbolicLink absoluteRoot
rootExists <-
inspectPath
absoluteRoot
(Directory.doesPathExist absoluteRoot)
rootIsDirectory <-
inspectPath
absoluteRoot
(Directory.doesDirectoryExist absoluteRoot)
when
((rootIsLink || rootExists) && not rootIsDirectory)
(throwE (HtmlOutputRootNotDirectory absoluteRoot))
canonicalRoot <-
inspectPath
absoluteRoot
(Directory.canonicalizePath absoluteRoot)
planned <- for (List.sort destinations)
\relative -> do
let components =
Posix.splitDirectories
(safeRelativePathFilePath relative)
destination =
confinedDestination
absoluteRoot
components
preflightDestination
canonicalRoot
absoluteRoot
components
destination
pure
( relative
, destination
)
pure (HtmlRoutePlan planned)
planHtmlOutputAgainst
:: HtmlRoutePlan
-> [PreparedHtmlArtifact]
-> Either HtmlOutputError HtmlOutputPlan
planHtmlOutputAgainst
(HtmlRoutePlan routes)
artifacts
| null artifacts =
Left EmptyPreparedHtmlOutput
| duplicate : _ <- duplicateDestinations preparedDestinations =
Left (DuplicatePreparedHtmlDestination duplicate)
| Set.fromList plannedDestinations
/= Set.fromList preparedDestinations =
Left
(HtmlOutputRouteMismatch
plannedDestinations
preparedDestinations)
| otherwise = HtmlOutputPlan <$> traverse attach artifacts
where
plannedDestinations = fst <$> routes
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]
-> [SafeRelativePath]
duplicateDestinations destinations =
[ destination
| (destination, multiplicity) <-
Map.toAscList
(Map.fromListWith (+)
[ (destination, 1 :: Int)
| destination <- destinations
])
, multiplicity > 1
]
data HtmlPublicationError = IncompleteHtmlPublication
{ committedHtmlDestinations :: ![SafeRelativePath]
, failedHtmlDestination :: !SafeRelativePath
, htmlPublicationFailure :: !Text
}
deriving stock (Show, Eq)
instance Exception HtmlPublicationError
renderHtmlPublicationError :: HtmlPublicationError -> [Text]
renderHtmlPublicationError failure =
[ "HTML publication failed at "
<> quoteRelative (failedHtmlDestination failure)
<> ": " <> htmlPublicationFailure failure
]
<> case committedHtmlDestinations failure of
[] -> []
committed ->
[ "HTML files published before the failure: "
<> Text.intercalate ", "
(quoteRelative <$> committed)
]
-- | 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) =
publishAll [] planned
publishAll
:: [SafeRelativePath]
-> [PlannedHtmlArtifact]
-> IO (Either HtmlPublicationError ())
publishAll _committed [] =
pure (Right ())
publishAll
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]
-> SafeRelativePath
-> IOException
-> HtmlPublicationError
publicationError committed failed err =
IncompleteHtmlPublication
{ committedHtmlDestinations = committed
, failedHtmlDestination = failed
, htmlPublicationFailure =
Text.pack (displayException err)
}
cleanupTemporary :: (FilePath, Handle) -> IO ()
cleanupTemporary (temporary, handle) = do
void (tryIOError (hClose handle))
void (tryIOError (Directory.removeFile temporary))
tryIOException :: IO a -> IO (Either IOException a)
tryIOException =
Exception.try
preflightDestination
:: FilePath
-> FilePath
-> [FilePath]
-> FilePath
-> ExceptT HtmlOutputError IO ()
preflightDestination canonicalRoot outputRoot components destination = do
traverse_
(preflightParent canonicalRoot)
(destinationParents outputRoot components)
preflightTarget destination
destinationParents :: FilePath -> [FilePath] -> [FilePath]
destinationParents root components =
take
(length components)
(scanl (Posix.</>) root components)
confinedDestination :: FilePath -> [FilePath] -> FilePath
confinedDestination =
foldl' (Posix.</>)
preflightParent
:: FilePath
-> FilePath
-> ExceptT HtmlOutputError IO ()
preflightParent canonicalRoot parent = do
parentIsLink <- inspectSymbolicLink parent
parentExists <-
inspectPath parent (Directory.doesPathExist parent)
parentIsDirectory <-
inspectPath parent (Directory.doesDirectoryExist parent)
when (parentIsLink || parentExists) do
unless
parentIsDirectory
(throwE (HtmlOutputParentNotDirectory parent))
canonicalParent <-
inspectPath
parent
(Directory.canonicalizePath parent)
unless
(isComponentwiseChild canonicalRoot canonicalParent)
(throwE
(HtmlOutputParentEscapesRoot
parent
canonicalParent))
preflightTarget
:: FilePath
-> ExceptT HtmlOutputError IO ()
preflightTarget target = do
statusResult <-
liftIO
(tryIOError
(PosixFiles.getSymbolicLinkStatus target))
case statusResult of
Left err
| isDoesNotExistError err ->
pure ()
| otherwise ->
throwE
(HtmlOutputPathInspectionFailed
target
(Text.pack (displayException err)))
Right status
| PosixFiles.isSymbolicLink status ->
throwE
(HtmlOutputTargetIsSymbolicLink target)
| PosixFiles.isRegularFile status ->
pure ()
| otherwise ->
throwE
(HtmlOutputTargetNotRegularFile target)
isComponentwiseChild :: FilePath -> FilePath -> Bool
isComponentwiseChild root child =
canonicalComponents root
`List.isPrefixOf`
canonicalComponents child
canonicalComponents :: FilePath -> [FilePath]
canonicalComponents =
Posix.splitDirectories
. Posix.dropTrailingPathSeparator
inspectSymbolicLink
:: FilePath
-> ExceptT HtmlOutputError IO Bool
inspectSymbolicLink path = do
result <-
liftIO
(tryIOError
(Directory.pathIsSymbolicLink path))
case result of
Right isLink ->
pure isLink
Left err
| isDoesNotExistError err ->
pure False
| otherwise ->
throwE
(HtmlOutputPathInspectionFailed
path
(Text.pack (displayException err)))
inspectPath
:: FilePath
-> IO a
-> ExceptT HtmlOutputError IO a
inspectPath path action = do
result <- liftIO (tryIOError action)
case result of
Right value ->
pure value
Left err ->
throwE
(HtmlOutputPathInspectionFailed
path
(Text.pack (displayException err)))
|