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
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
|
{-# LANGUAGE QuasiQuotes #-}
module Sqlitefs where
import Builder
import Control.Exception (Exception (displayException), catch)
import Data.ByteString qualified as B
import Data.ByteString qualified as ByteString
import Data.Error.Tree (errorTreeContext)
import Data.Graph (Tree)
import Data.IORef
import Data.List qualified as List
import Data.Text qualified as Text
import Data.Time.Clock.POSIX qualified as Posix
import Data.Tree qualified as Tree
import Database.SQLite.Simple (Connection, NamedParam ((:=)))
import Database.SQLite.Simple qualified as Sqlite
import Database.SQLite.Simple.QQ (sql)
import Divisive
import Hedgehog (Gen, Range)
import Hedgehog qualified as Hedge
import Hedgehog.Gen qualified as Gen
import Hedgehog.Range qualified as Range
import Label
import MyLabel
import MyPrelude
import Parse qualified
import System.Directory (XdgDirectory (..), createDirectoryIfMissing, getXdgDirectory)
import System.Environment (getArgs, withArgs)
import System.Exit (exitFailure)
import System.Fuse hiding (ReadOnly, ReadWrite, WriteOnly)
import System.Fuse qualified as Fuse
import System.IO (hPutStrLn, stderr)
import System.Posix (ByteCount, DeviceID, EpochTime, FileMode, FileOffset, defaultFileFlags, groupWriteMode, ownerWriteMode)
import System.Posix.Files
( groupReadMode,
otherReadMode,
ownerReadMode,
)
import Test
import Test.Hspec qualified as Hspec
import Test.Hspec.Hedgehog (hedgehog)
-- TODO: reading the content into the state struct seems to be the only way
-- to assert the file is still readable from its fd even if the row is deleted
-- in-between open and read.
type State = T2 "content" ByteString "readWrite" ReadWrite
-- | Depending on whether the file system is mounted read-only or read-write,
-- and if the table is a view or not, we can determine the read/write mode.
data EnvReadOnly = EnvReadOnly | EnvReadWrite
data ReadWrite
= WriteOnly
| ReadWrite
| ReadOnly ReadOnlyReason
deriving stock (Show, Eq)
data ReadOnlyReason = IsView | ModeRequested
deriving stock (Show, Eq)
-- | Parse command line arguments to extract database path
-- Supports: program [options] mountpoint [-- dbpath] or program [options] mountpoint dbpath
parseArgs :: [String] -> IO String
parseArgs args = case findDashDash args of
Just dashIndex ->
case drop (dashIndex + 1) args of
(dbPath : _) -> pure dbPath
[] -> do
putStderrLn "Error: -- found but no database path argument follows"
exitFailure
Nothing ->
case reverse (filter (not . isPrefixOf "-") args) of
(dbPath : _) -> pure dbPath
[] -> do
putStderrLn "Error: No database path provided. Usage: sqlitefs [options] mountpoint [-- dbpath] or sqlitefs [options] mountpoint dbpath"
exitFailure
where
findDashDash :: [String] -> Maybe Int
findDashDash xs = List.findIndex (== "--") xs
isPrefixOf :: String -> String -> Bool
isPrefixOf [] _ = True
isPrefixOf _ [] = False
isPrefixOf (x:xs) (y:ys) = x == y && isPrefixOf xs ys
-- | Filter out database path and -- separator from arguments for FUSE
filterDatabasePath :: [String] -> String -> [String]
filterDatabasePath args dbPath = filter shouldKeep args
where
shouldKeep arg = arg /= dbPath && arg /= "--"
main :: IO ()
main = do
args <- getArgs
databasePath <- parseArgs args
let filteredArgs = filterDatabasePath args databasePath
putStderrLn $ "Using database path: " <> showToText databasePath
withSqlite databasePath $ \conn -> do
let tableName = "files"
envReadOnly <-
Sqlite.queryNamed conn "SELECT name, type FROM sqlite_schema WHERE name = :name" [":name" := (tableName :: Text)] >>= \case
[] -> do
putStderrLn [fmt|No table named '{tableName}' exists, creating it|]
createTestTable conn
putStderrLn [fmt|Table '{tableName}' created successfully, mounting read-write|]
-- Enable WAL mode for better concurrency in read-write mode
Sqlite.execute_ conn "PRAGMA journal_mode=WAL" `Control.Exception.catch` \(e :: Sqlite.SQLError) -> do
putStderrLn [fmt|Warning: Failed to enable WAL mode: {displayException e}|]
putStderrLn "Continuing with default journal mode"
pure EnvReadWrite
((name, type_) : _ :: [(Text, Text)]) -> do
-- TODO: also detect here if the columns we want are there
putStderrLn [fmt|Table "{name}" exists and is a {type_}|]
-- if type is 'view', we should enforce read-only mode for now
case type_ of
"view" -> do
putStderrLn [fmt|Table "{name}" is a view, mounting read-only|]
pure EnvReadOnly
"table" -> do
putStderrLn [fmt|Table "{name}" is a table, mounting read-write|]
-- Enable WAL mode for better concurrency in read-write mode
Sqlite.execute_ conn "PRAGMA journal_mode=WAL" `Control.Exception.catch` \(e :: Sqlite.SQLError) -> do
putStderrLn [fmt|Warning: Failed to enable WAL mode: {displayException e}|]
putStderrLn "Continuing with default journal mode"
pure EnvReadWrite
other -> do
putStderrLn [fmt|Table "{name}" is of unknown type "{other}", mounting read-only|]
pure EnvReadOnly
withArgs filteredArgs $ fuseMain
defaultFuseOps'
{ fuseGetFileStat = myFuseGetFileStat conn envReadOnly,
fuseOpen = myFuseOpen conn envReadOnly,
fuseRead = myFuseRead conn,
fuseCreateDevice = myFuseCreateDevice conn,
fuseSetFileTimes = myFuseSetFileTimes conn,
fuseSynchronizeFile = myFuseSynchronizeFile conn,
fuseFlush = myFuseFlush conn,
fuseWrite = myFuseWrite conn,
fuseRemoveLink = myFuseRemoveLink conn,
fuseRelease = myFuseRelease conn,
fuseOpenDirectory = myFuseOpenDirectory conn,
fuseReadDirectory = myFuseReadDirectory conn,
fuseReleaseDirectory = myFuseReleaseDirectory conn
}
defaultExceptionHandler
myFuseGetFileStat :: Connection -> EnvReadOnly -> FilePath -> IO (Either Errno FileStat)
myFuseGetFileStat conn envReadOnly path = do
putStderrLn $ "fuseGetFileStat: " <> showToText path
ctx <- getFuseContext
-- Special case for root directory
if path == "/"
then do
putStderrLn "fuseGetFileStat: root directory"
pure $ Right $ fileStat envReadOnly 0 Directory ctx
else do
-- Check if this is a directory by looking for nested files
queryNamedWith
conn
[sql|
SELECT filecontent, COALESCE((SELECT 1 FROM files WHERE filename LIKE :path || '/%' LIMIT 1), 0) as has_nested
FROM files
WHERE filename = :path
LIMIT 1
|]
[":path" := (List.stripPrefix "/" path & fromMaybe path)]
( do
content <- columnIdx 0 "filecontent" <&> sqlDataToBytestring
hasNested <- columnIdx 1 "has_nested" >>> sqlBool
pure (content, hasNested)
)
>>= \case
[] -> do
putStderrLn $ "fuseGetFileStat: file not found: " <> showToText path
pure $ Left eNOENT
((content, hasNested) : _) -> do
putStderrLn $ "fuseGetFileStat: file found: " <> showToText path
let entryType = if hasNested then Directory else RegularFile
pure $ Right $ fileStat envReadOnly (fromIntegral $ ByteString.length content) entryType ctx
myFuseOpen :: Connection -> EnvReadOnly -> FilePath -> OpenMode -> OpenFileFlags -> IO (Either Errno State)
myFuseOpen conn envReadOnly path mode flags = do
putStderrLn $ "fuseOpen: " <> showToText (path, mode, flags)
when
( flags
{ -- ignore nonBlock
nonBlock = False
}
/= defaultFileFlags
)
$ do
putStderrLn $ "WARN fuseOpen: no open flags supported yet, got: " <> showToText flags
readWrite <- case envReadOnly of
EnvReadWrite -> case mode of
Fuse.ReadOnly -> do
putStderrLn "fuseOpen: opening file in read-only mode on read-write file system"
pure $ ReadOnly ModeRequested
Fuse.WriteOnly -> do
putStderrLn "fuseOpen: opening file in write-only mode on read-write file system"
pure WriteOnly
Fuse.ReadWrite -> do
putStderrLn "fuseOpen: opening file in read-write mode on read-write file system"
pure ReadWrite
EnvReadOnly -> case mode of
Fuse.ReadOnly -> do
putStderrLn "fuseOpen: opening file in read-only mode on read-only file system"
pure $ ReadOnly IsView
Fuse.WriteOnly -> do
putStderrLn "fuseOpen: cannot open file in write-only mode on read-only file system"
pure $ ReadOnly IsView
Fuse.ReadWrite -> do
putStderrLn "fuseOpen: cannot open file in read-write mode on read-only file system"
pure $ ReadOnly IsView
queryNamedWith
conn
"SELECT filecontent FROM files WHERE filename = :path"
[":path" := (List.stripPrefix "/" path & fromMaybe path)]
(columnIdx 0 "filecontent" <&> sqlDataToBytestring)
>>= \case
[] -> do
putStderrLn $ "fuseOpen: file not found, creating: " <> showToText path
-- Create empty file when opening non-existent file for writing
case readWrite of
ReadOnly _ -> do
putStderrLn $ "fuseOpen: cannot create file in read-only mode: " <> showToText path
pure $ Left eNOENT
_ -> do
-- Create empty file in database
Sqlite.executeNamed
conn
"INSERT INTO files (filename, filecontent) VALUES (:path, :content) ON CONFLICT(filename) DO UPDATE SET filecontent = :content"
[":path" := (List.stripPrefix "/" path & fromMaybe path), ":content" := B.empty]
putStderrLn $ "fuseOpen: created empty file: " <> showToText path
pure $ Right $ t2 #content B.empty #readWrite readWrite
(x : _) -> do
putStderrLn $ "fuseOpen: file found: " <> showToText path
pure $ Right $ t2 #content x #readWrite readWrite
myFuseRead :: Connection -> FilePath -> State -> ByteCount -> FileOffset -> IO (Either Errno ByteString)
myFuseRead _ path state byteCount offset = do
-- TODO: ideally, we'd use the BLOB API here to read only the requested bytes from the file system
putStderrLn ("fuseRead: " <> showToText (path, byteCount, offset))
pure $ Right $ B.take (fromIntegral byteCount) $ B.drop (fromIntegral offset) state.content
myFuseSetFileTimes :: Connection -> FilePath -> EpochTime -> EpochTime -> IO Errno
myFuseSetFileTimes _conn path _atime _mtime = do
putStderrLn $ "fuseSetFileTimes: " <> showToText (path, _atime, _mtime)
pure eOK
myFuseCreateDevice :: Connection -> FilePath -> EntryType -> FileMode -> DeviceID -> IO Errno
myFuseCreateDevice conn path entryType mode dev = do
case entryType of
RegularFile -> do
putStderrLn $ "fuseCreateDevice: creating regular file: " <> showToText (path, mode, dev)
Sqlite.executeNamed
conn
"INSERT INTO files (filename, filecontent) VALUES (:path, :content) ON CONFLICT(filename) DO UPDATE SET filecontent = :content"
[":path" := (List.stripPrefix "/" path & fromMaybe path), ":content" := B.empty]
pure eOK
_ -> do
putStderrLn $ "fuseCreateDevice: cannot create device of type " <> showToText entryType <> " at path: " <> showToText path
pure eNOSYS
myFuseSynchronizeFile :: Connection -> FilePath -> SyncType -> IO Errno
myFuseSynchronizeFile _conn path _syncType = do
putStderrLn $ "fuseSynchronizeFile: " <> showToText (path)
pure eOK
myFuseFlush :: Connection -> FilePath -> State -> IO Errno
myFuseFlush _conn path state = do
putStderrLn $ "fuseFlush: " <> showToText (path, state)
pure eOK
myFuseRelease :: Connection -> FilePath -> State -> IO ()
myFuseRelease _conn path state = do
putStderrLn $ "fuseRelease: " <> showToText (path, state)
pure ()
myFuseWrite :: Connection -> FilePath -> State -> ByteString -> FileOffset -> IO (Either Errno ByteCount)
myFuseWrite conn path state bs offset = do
putStderrLn $ "fuseWrite: " <> showToText (path, state, bs, offset)
case state.readWrite of
ReadOnly reason -> do
putStderrLn [fmt|fuseWrite: cannot write to read-only file, reason: {show reason}|]
pure $ Left eROFS
WriteOnly -> write
ReadWrite -> write
where
write :: IO (Either Errno ByteCount)
write = do
-- putStderrLn $ "fuseWrite: writing " <> showToText (B.length bs) <> " bytes at offset " <> showToText offset
-- TODO: we are writing the whole file here, instead we should use the blob API to write only the requested bytes.
let newContent =
buildBytes
(dt3 #before bytesB #new bytesB #after bytesB)
( t3
#before
(B.take (fromIntegral offset) state.content)
#new
bs
#after
(B.drop (fromIntegral offset) state.content)
)
-- TODO: catch sqlite errors and return appropriate Errno
Sqlite.executeNamed
conn
"INSERT INTO files (filename, filecontent) VALUES (:path, :newContent) ON CONFLICT(filename) DO UPDATE SET filecontent = :newContent"
[":newContent" := newContent, ":path" := (List.stripPrefix "/" path & fromMaybe path)]
pure $ Right (fromIntegral $ ByteString.length bs)
myFuseOpenDirectory :: Connection -> FilePath -> IO Errno
myFuseOpenDirectory _conn fp = do
putStderrLn $ "fuseOpenDirectory: " <> showToText fp
if
| fp == "" -> pure eNOENT
| fp == "/" -> pure eOK
| otherwise -> do
-- check that we have any files which have the directory as prefix, with a trailing slash, otherwise return eNOENT
queryNamedWith
_conn
"SELECT filename FROM files WHERE filename LIKE :path || '/%' LIMIT 1"
[":path" := (List.stripPrefix "/" fp & fromMaybe fp)]
(pure ())
>>= \case
[] -> do
putStderrLn $ "fuseOpenDirectory: no files found for path: " <> showToText fp
pure eNOENT
_ -> do
putStderrLn $ "fuseOpenDirectory: found files for path: " <> showToText fp
pure eOK
myFuseRemoveLink :: Connection -> FilePath -> IO Errno
myFuseRemoveLink _conn path = do
putStderrLn $ "fuseRemoveLink: " <> showToText path
Sqlite.executeNamed
_conn
"DELETE FROM files WHERE filename = :path"
[":path" := (List.stripPrefix "/" path & fromMaybe path)]
pure eOK
myFuseReadDirectory :: Connection -> FilePath -> IO (Either Errno [(FilePath, FileStat)])
myFuseReadDirectory _conn fp = do
putStderrLn $ "fuseReadDirectory: " <> showToText fp
ctx <- getFuseContext
-- We do the prefix match like in fuseOpenDirectory, but we also need to
-- ensure that we only return files that are direct children of the directory,
-- i.e. that do not have a slash in the name after the prefix.
let pathParam = case fp of
"/" -> "" -- For root directory, use empty string
_ -> List.stripPrefix "/" fp & fromMaybe fp
queryNamedWith
_conn
[sql|
SELECT filename, filecontent, COALESCE((SELECT 1 FROM files WHERE filename LIKE f.filename || '/%' LIMIT 1), 0) as has_nested
FROM files f
WHERE CASE
WHEN :path = '' THEN
-- Root directory: show files without slashes AND directories (files with exactly one slash)
(filename NOT LIKE '%/%' OR (filename LIKE '%/%' AND filename NOT LIKE '%/%/%'))
ELSE
-- Subdirectory: original logic
((filename = :path OR filename LIKE :path || '/%') AND filename NOT LIKE :path || '/%/%')
END
ORDER BY filename
|]
[":path" := pathParam]
( do
name <- columnIdx 0 "filename" <&> sqlDataToText <&> textToString
content <- columnIdx 1 "filecontent" <&> sqlDataToBytestring
hasNestedFiles <- columnIdx 2 "has_nested" >>> sqlBool
pure $ do
let entryType = if hasNestedFiles then Directory else RegularFile
( name,
fileStat
EnvReadWrite
(fromIntegral $ ByteString.length content)
entryType
ctx
)
)
>>= \case
[] -> do
putStderrLn $ "fuseReadDirectory: no files found for path: " <> showToText fp
pure $ Left eNOENT
rows -> do
putStderrLn $ "fuseReadDirectory: found files for path: " <> showToText fp
pure $ Right rows
myFuseReleaseDirectory :: Connection -> FilePath -> IO Errno
myFuseReleaseDirectory _conn path = do
putStderrLn $ "fuseReleaseDirectory: " <> showToText path
pure eOK
fileStat :: EnvReadOnly -> FileOffset -> EntryType -> FuseContext -> FileStat
fileStat envReadOnly fileSize entryType ctx =
FileStat
{ statEntryType = entryType,
statFileMode =
foldr1
unionFileModes
( case envReadOnly of
EnvReadWrite -> [ownerReadMode, ownerWriteMode, groupReadMode, groupWriteMode, otherReadMode]
EnvReadOnly -> [ownerReadMode, groupReadMode, otherReadMode]
),
statLinkCount = 0,
statFileOwner = fuseCtxUserID ctx,
statFileGroup = fuseCtxGroupID ctx,
statSpecialDeviceID = 0,
statFileSize = fileSize,
statBlocks = fromIntegral fileSize `div` 512,
statAccessTime = 0,
statModificationTime = 0,
statStatusChangeTime = 0
}
withSqlite :: String -> (Sqlite.Connection -> IO a) -> IO a
withSqlite fileName inner = Sqlite.withConnection fileName $ \conn -> do
Sqlite.setTrace conn (Just (\msg -> hPutStrLn stderr (textToString [fmt|{fileName}: {msg}|])))
-- Set busy timeout for better concurrent access handling
Sqlite.execute_ conn "PRAGMA busy_timeout = 5000"
inner conn
queryNamedWith :: Sqlite.Connection -> Sqlite.Query -> [NamedParam] -> Parse.Parse [Sqlite.SQLData] a -> IO [a]
queryNamedWith conn qry params parse = do
rows <- Sqlite.queryNamed @[Sqlite.SQLData] conn qry params
Parse.runParse "parse errors" (Parse.multiple parse) rows
& first (errorTreeContext [fmt|Error parsing query result|])
& unwrapIOErrorTree
columnIdx :: Natural -> Text -> Parse.Parse [Sqlite.SQLData] Sqlite.SQLData
columnIdx idx columnName = Parse.mkParsePushContext [fmt|[column {idx} ({columnName})|] $ \from -> case from List.!? (fromIntegral @_ @Int idx) of
Nothing -> Left [fmt|Column with index {idx} does not exist (out of bounds)|]
Just a -> Right a
sqlDataToBytestring :: Sqlite.SQLData -> ByteString
sqlDataToBytestring = \case
Sqlite.SQLBlob bs -> bs
Sqlite.SQLText txt -> textToBytesUtf8 txt
Sqlite.SQLInteger i -> buildBytes int64DecimalB i
Sqlite.SQLFloat f -> buildBytes doubleDecimalB f
Sqlite.SQLNull -> B.empty
sqlDataToText :: Sqlite.SQLData -> Text
sqlDataToText s = s & sqlDataToBytestring & bytesToTextUtf8Lenient
sqlBool :: Parse.Parse Sqlite.SQLData Bool
sqlBool = Parse.mkParseNoContext $ \case
Sqlite.SQLInteger 0 -> Right False
Sqlite.SQLInteger 1 -> Right True
s -> Left [fmt|Expected boolean (0 or 1), got {prettySqlData s}|]
-- do length restriction on the length of output, put … if the string is too long
prettySqlData :: Sqlite.SQLData -> Text
prettySqlData s =
s & sqlDataToBytestring & bytesToTextUtf8Lenient & \txt ->
if Text.length txt > 100
then Text.take 100 txt <> "…"
else txt
-- | Empty \/ default versions of the FUSE operations.
defaultFuseOps' :: FuseOperations State
defaultFuseOps' =
FuseOperations
{ fuseGetFileStat = \path -> do
putStderrLn $ "fuseGetFileStat not implemented: " <> showToText path
pure (Left eNOSYS),
fuseReadSymbolicLink = \path -> do
putStderrLn $ "fuseReadSymbolicLink not implemented: " <> showToText path
pure (Left eNOSYS),
fuseCreateDevice = \path entryType mode dev -> do
putStderrLn $ "fuseCreateDevice not implemented" <> showToText (path, entryType, mode, dev)
pure eNOSYS,
fuseCreateDirectory = \path mode -> do
putStderrLn $ "fuseCreateDirectory not implemented: " <> showToText (path, mode)
pure eNOSYS,
fuseRemoveLink = \path -> do
putStderrLn $ "fuseRemoveLink not implemented: " <> showToText path
pure eNOSYS,
fuseRemoveDirectory = \path -> do
putStderrLn $ "fuseRemoveDirectory not implemented: " <> showToText path
pure eNOSYS,
fuseCreateSymbolicLink = \target linkPath -> do
putStderrLn $ "fuseCreateSymbolicLink not implemented: " <> showToText (target, linkPath)
pure eNOSYS,
fuseRename = \oldPath newPath -> do
putStderrLn $ "fuseRename not implemented: " <> showToText (oldPath, newPath)
pure eNOSYS,
fuseCreateLink = \target linkPath -> do
putStderrLn $ "fuseCreateLink not implemented: " <> showToText (target, linkPath)
pure eNOSYS,
fuseSetFileMode = \path mode -> do
putStderrLn $ "fuseSetFileMode not implemented: " <> showToText (path, mode)
pure eNOSYS,
fuseSetOwnerAndGroup = \path owner group -> do
putStderrLn $ "fuseSetOwnerAndGroup not implemented: " <> showToText (path, owner, group)
pure eNOSYS,
fuseSetFileSize = \path size -> do
putStderrLn $ "fuseSetFileSize not implemented: " <> showToText (path, size)
pure eNOSYS,
fuseSetFileTimes = \path atime mtime -> do
putStderrLn $ "fuseSetFileTimes not implemented: " <> showToText (path, atime, mtime)
pure eNOSYS,
fuseOpen = \path mode flags -> do
putStderrLn $ "fuseOpen not implemented: " <> showToText (path, mode, flags)
pure (Left eNOSYS),
fuseRead = \path state byteCount offset -> do
putStderrLn $ "fuseRead not implemented: " <> showToText (path, state, byteCount, offset)
pure (Left eNOSYS),
fuseWrite = \path state bs offset -> do
putStderrLn $ "fuseWrite not implemented: " <> showToText (path, state, bs, offset)
pure (Left eNOSYS),
fuseGetFileSystemStats = \path -> do
putStderrLn $ "fuseGetFileSystemStats not implemented: " <> showToText path
pure (Left eNOSYS),
fuseFlush = \path state -> do
putStderrLn $ "fuseFlush not implemented: " <> showToText (path, state)
pure eOK,
fuseRelease = \path state -> do
putStderrLn $ "fuseRelease not implemented: " <> showToText (path, state)
pure (),
fuseSynchronizeFile = \path _syncType -> do
putStderrLn $ "fuseSynchronizeFile not implemented: " <> showToText (path)
pure eNOSYS,
fuseOpenDirectory = \path -> do
putStderrLn $ "fuseOpenDirectory not implemented: " <> showToText path
pure eNOSYS,
fuseReadDirectory = \path -> do
putStderrLn $ "fuseReadDirectory not implemented: " <> showToText path
pure (Left eNOSYS),
fuseReleaseDirectory = \path -> do
putStderrLn $ "fuseReleaseDirectory not implemented: " <> showToText path
pure eNOSYS,
fuseSynchronizeDirectory = \path _syncType -> do
putStderrLn $ "fuseSynchronizeDirectory not implemented: " <> showToText (path)
pure eNOSYS,
fuseAccess = \path mode -> do
putStderrLn $ "fuseAccess not implemented: " <> showToText (path, mode)
pure eNOSYS,
fuseInit = do
putStderrLn "FUSE_INIT_COMPLETE"
pure (),
fuseDestroy = do
putStderrLn "fuseDestroy not implemented"
pure ()
}
data Sqlite = Sqlite
{ connection :: Connection,
hasFailed :: IORef Bool
}
withMemorySqlite :: (Connection -> IO ()) -> SpecWith Sqlite -> Spec
withMemorySqlite createTable spec = do
hasFailed <- Hspec.runIO $ newIORef False
Test.aroundAll
( \act ->
withSqlite
":memory:"
( \conn -> do
createTable conn
act $ Sqlite {connection = conn, hasFailed}
)
)
$ Test.afterAll
( \sqlite -> do
readIORef hasFailed >>= \case
True -> do
-- get temporary file to dump into
tmpFile <- do
let tmpl = dt2 #cache stringT #timestamp ("/test-" <> nominalDiffTimeSecondsT <> ".db")
cache <- getXdgDirectory XdgCache "sqlitefs"
createDirectoryIfMissing True cache
timestamp <- Posix.getPOSIXTime
pure $ buildText tmpl $ t2 #cache cache #timestamp timestamp
putStderrLn $ "Test failed, dumping database to " <> tmpFile
Sqlite.executeNamed (sqlite.connection) "VACUUM INTO :path" [":path" := tmpFile]
False -> pure ()
)
( Test.aroundWith
(\act sqlite -> Sqlite.withTransaction (sqlite.connection) (act sqlite))
spec
)
createTestTable :: Connection -> IO ()
createTestTable conn = do
Sqlite.executeNamed
conn
[sql|
CREATE TABLE IF NOT EXISTS files (
filename TEXT PRIMARY KEY,
filecontent BLOB
)
|]
[]
-- So I want to create a tree of files, and then the property to check should be that
-- the file content is the same as the file content of the file in the tree.
-- listing a directory should return the children of the directory in the tree object, and they should be directories if they have children themselves, and files otherwise.
test_list_directory :: Test.Spec
test_list_directory = describe "test_list_directory" $ withMemorySqlite createTestTable $ do
it "lists a directory" $ \sqlite -> do
hedgehog $ do
files <- Hedge.forAll $ genTree (t2 #depth (Range.linear 0 2) #width (Range.linear 1 2)) (Gen.text (Range.linear 1 2) (Gen.element ['a' .. 'c']))
-- insert all files
for_ files $ \file -> do
liftIO $
Sqlite.executeNamed
(sqlite.connection)
[sql|
INSERT INTO files (filename, filecontent)
VALUES (:filename, :filecontent) ON CONFLICT(filename)
DO UPDATE SET filecontent = :filecontent
|]
[":filename" := file, ":filecontent" := B.empty]
-- list the directory via fuseReadDirectory
(liftIO $ myFuseReadDirectory (sqlite.connection) "/") >>= \case
Left e -> sqlite & hedgeFailure [fmt|error: {displayException $ errnoToIOError "" e Nothing Nothing}|]
Right rows -> do
Hedge.annotateShow rows
Hedge.assert $ length rows == length files
hedgeFailure :: (HasCallStack) => Text -> Sqlite -> Hedge.PropertyT IO ()
hedgeFailure msg sqlite = do
liftIO $ writeIORef (sqlite.hasFailed) True
Hedge.annotate (textToString msg)
Hedge.failure
test_directory_traversal :: Test.Spec
test_directory_traversal = describe "test_directory_traversal" $ do
describe "root directory listing" $ withMemorySqlite createTestTable $ do
it "lists root directory correctly" $ \sqlite -> do
-- Insert test files: topfile (root level), a/b and a/c (directory a)
Sqlite.executeNamed (sqlite.connection) "INSERT INTO files (filename, filecontent) VALUES (:filename, :filecontent)" [(":filename" := ("topfile" :: String)), (":filecontent" := ("top level content" :: ByteString))]
Sqlite.executeNamed (sqlite.connection) "INSERT INTO files (filename, filecontent) VALUES (:filename, :filecontent)" [(":filename" := ("a/b" :: String)), (":filecontent" := ("test content" :: ByteString))]
Sqlite.executeNamed (sqlite.connection) "INSERT INTO files (filename, filecontent) VALUES (:filename, :filecontent)" [(":filename" := ("a/c" :: String)), (":filecontent" := ("more content" :: ByteString))]
-- Test root directory listing
myFuseReadDirectory (sqlite.connection) "/" >>= \case
Left _e -> testErr "Root directory listing failed"
Right rows -> do
let filenames = map fst rows
filenames `shouldBe` ["a/b", "a/c", "topfile"] -- Should show all files in root and at one level deep
describe "subdirectory listing" $ withMemorySqlite createTestTable $ do
it "lists subdirectory correctly" $ \sqlite -> do
-- Insert test files in subdirectory structure
Sqlite.executeNamed (sqlite.connection) "INSERT INTO files (filename, filecontent) VALUES (:filename, :filecontent)" [(":filename" := ("a/file1" :: String)), (":filecontent" := ("content1" :: ByteString))]
Sqlite.executeNamed (sqlite.connection) "INSERT INTO files (filename, filecontent) VALUES (:filename, :filecontent)" [(":filename" := ("a/file2" :: String)), (":filecontent" := ("content2" :: ByteString))]
Sqlite.executeNamed (sqlite.connection) "INSERT INTO files (filename, filecontent) VALUES (:filename, :filecontent)" [(":filename" := ("a/sub/file3" :: String)), (":filecontent" := ("content3" :: ByteString))]
-- Test subdirectory listing
myFuseReadDirectory (sqlite.connection) "/a" >>= \case
Left _e -> testErr "Subdirectory listing failed"
Right rows -> do
let filenames = map fst rows
filenames `shouldBe` ["a/file1", "a/file2"] -- Should only show direct children, not a/sub/file3
-- test_create_and_read_file :: Test.Spec
-- test_create_and_read_file = describe "test_create_and_read_file" $ withMemorySqlite createTestTable $ do
-- it "creates and reads a file" $ \conn -> do
-- hedgehog $ do
-- files <- Hedge.forAll $ genTree (t2 #depth (Range.linear 0 2) #width (Range.linear 1 2)) (Gen.text (Range.linear 1 2) (Gen.element ['a' .. 'c']))
-- dat <- Hedge.forAll $ do
-- filename <-
-- genTree (t2 #depth (Range.linear 0 2) #width (Range.linear 1 2)) (Gen.text (Range.linear 1 2) (Gen.element ['a' .. 'c']))
-- <&> treeToPathList
-- <&> Text.intercalate "/"
-- filecontents <- Gen.text (Range.exponential 0 100) Gen.alphaNum
-- pure $ t2 #filename filename #filecontents filecontents
-- liftIO $ do
-- Sqlite.executeNamed
-- conn
-- [sql|
-- INSERT INTO files (filename, filecontent)
-- VALUES (:filename, :filecontents) ON CONFLICT(filename)
-- DO UPDATE SET filecontent = :filecontents
-- |]
-- [":filename" := dat.filename, ":filecontents" := textToBytesUtf8 dat.filecontents]
-- rows <-
-- liftIO $
-- queryNamedWith
-- conn
-- [sql|
-- SELECT filecontent FROM files WHERE filename = :filename
-- |]
-- [":filename" := dat.filename]
-- (columnIdx 0 "filecontent" <&> sqlDataToText)
-- case rows of
-- [content] -> Hedge.assert $ content == dat.filecontents
-- _ -> do
-- Hedge.annotate "Expected exactly one row"
-- Hedge.failure
genTree :: forall a. T2 "depth" (Range Int) "width" (Range Int) -> Gen a -> Gen (Tree a)
genTree dat genVal = do
depth <- Gen.int dat.depth
go depth
where
go :: Int -> Gen (Tree a)
go 0 = genVal <&> \v -> Tree.Node v []
go d = do
v <- genVal
children <- Gen.list dat.width (go (d - 1))
pure $ Tree.Node v children
tryGenTree :: IO ()
tryGenTree = runTest $ it "tryGenTree" $ hedgehog $ do
tree <- Hedge.forAll $ genTree (t2 #depth (Range.linear 0 2) #width (Range.linear 1 2)) (Gen.text (Range.linear 1 4) (Gen.element ['a' .. 'c']))
liftIO $ print $ treeToPathList tree & map (Text.intercalate "/")
pure ()
-- Create the full path for every element in the tree
treeToPathList :: Tree a -> [[a]]
treeToPathList (Tree.Node value children) =
[value] : concatMap (map (value :)) (treeToPathList <$> children)
foo :: IO ()
foo = (Gen.print $ genTree (t2 #depth (Range.linear 1 2) #width (Range.linear 1 2)) (Gen.text (Range.linear 1 4) (Gen.element ['a' .. 'c'])))
|