1{-# LANGUAGE FlexibleContexts #-}
2{-# LANGUAGE LambdaCase #-}
3{-# LANGUAGE OverloadedStrings #-}
4module Obelisk.Command.Project
6 , findProjectObeliskCommand
22import Control.Concurrent.MVar (MVar, newMVar, withMVarMasked)
23import Control.Lens ((.~), (?~), (<&>))
25import Control.Monad.IO.Class (liftIO)
26import Control.Monad.State
27import qualified Data.Aeson as Json
29import qualified Data.ByteString.Lazy as BSL
30import Data.Default (def)
31import Data.Function ((&), on)
32import Data.List (isInfixOf)
34import Data.Text (Text)
35import qualified Data.Text as T
36import Data.Text.Encoding (decodeUtf8)
37import Data.Traversable (for)
38import System.Directory
39import System.Environment (lookupEnv)
42import System.IO.Unsafe (unsafePerformIO)
43import System.Posix (FileStatus, FileMode, CMode (..), UserID, deviceID, fileID, fileMode, fileOwner, getFileStatus, getRealUserID)
44import System.Posix.Files
45import System.Process (waitForProcess)
47import GitHub.Data.GitData (Branch)
48import GitHub.Data.Name (Name)
50import Obelisk.App (MonadObelisk)
52import Obelisk.Command.Nix
53import Obelisk.Command.Thunk
54import Obelisk.Command.Utils (nixExePath, nixBuildExePath)
56--TODO: Make this module resilient to random exceptions
58--TODO: Don't hardcode this
59-- | Source for the Obelisk project
60obeliskSource :: ThunkSource
61obeliskSource = obeliskSourceWithBranch "master"
63-- | Source for obelisk developer targeting a specific obelisk branch
64obeliskSourceWithBranch :: Name Branch -> ThunkSource
65obeliskSourceWithBranch branch = ThunkSource_GitHub $ GitHubSource
66 { _gitHubSource_owner = "obsidiansystems"
67 , _gitHubSource_repo = "obelisk"
68 , _gitHubSource_branch = Just branch
69 , _gitHubSource_private = False
74 | InitSource_Branch (Name Branch)
75 | InitSource_Symlink FilePath
78obeliskDirName :: FilePath
79obeliskDirName = ".obelisk"
81-- | Path to obelisk directory in given path
82toObeliskDir :: FilePath -> FilePath
83toObeliskDir p = p </> obeliskDirName
85-- | Path to impl file in given path
86toImplDir :: FilePath -> FilePath
87toImplDir p = toObeliskDir p </> "impl"
89-- | Create a new project rooted in the current directory
90initProject :: MonadObelisk m => InitSource -> Bool -> m ()
91initProject source force = withSystemTempDirectory "ob-init" $ \tmpDir -> do
92 let implDir = toImplDir tmpDir
93 obDir = toObeliskDir tmpDir
94 liftIO (listDirectory ".") >>= \case
96 _ | force -> putLog Warning "Initializing in non-empty directory"
97 | otherwise -> failWith "ob init requires an empty directory. Use the flag --force to init anyway, potentially overwriting files."
98 skeleton <- withSpinner "Setting up obelisk" $ do
99 liftIO $ createDirectory obDir
101 InitSource_Default -> createThunkWithLatest implDir obeliskSource
102 InitSource_Branch branch -> createThunkWithLatest implDir $ obeliskSourceWithBranch branch
103 InitSource_Symlink path -> do
104 let symlinkPath = if isAbsolute path
107 liftIO $ createSymbolicLink symlinkPath implDir
108 _ <- nixBuildAttrWithCache implDir "command"
109 --TODO: We should probably handoff to the impl here
110 skel <- nixBuildAttrWithCache implDir "skeleton" --TODO: I don't think there's actually any reason to cache this
112 callProcessAndLogOutput (Notice, Error) $
121 withSpinner "Copying project skeleton" $ do
122 callProcessAndLogOutput (Notice, Error) $
125 , "--no-preserve=mode"
131 let configDir = "config"
132 createDirectoryIfMissing False configDir
133 mapM_ (createDirectoryIfMissing False . (configDir </>)) ["backend", "common", "frontend"]
135--TODO: Allow the user to ignore our security concerns
136-- | Find the Obelisk implementation for the project at the given path
137findProjectObeliskCommand :: MonadObelisk m => FilePath -> m (Maybe FilePath)
138findProjectObeliskCommand target = do
139 myUid <- liftIO getRealUserID
140 processUmask <- liftIO getUmask
141 targetStat <- liftIO $ getFileStatus target
142 (result, insecurePaths) <- flip runStateT [] $ walkToProjectRoot target targetStat processUmask myUid >>= \case
143 Nothing -> pure Nothing
144 Just projectRoot -> liftIO (doesDirectoryExist $ toImplDir projectRoot) >>= \case
146 putLog Warning $ "Found obelisk directory in " <> T.pack projectRoot <> " but the implementation (impl) file is missing"
149 walkToImplDir projectRoot myUid processUmask -- For security check
150 return $ Just projectRoot
151 case (result, insecurePaths) of
152 (Just projDir, []) -> do
153 obeliskCommandPkg <- nixBuildAttrWithCache (toImplDir projDir) "command"
154 return $ Just $ obeliskCommandPkg </> "bin" </> "ob"
155 (Nothing, _) -> return Nothing
156 (Just projDir, _) -> do
157 putLog Error $ T.unlines
158 [ "Error: Found a project at " <> T.pack (normalise projDir) <> ", but had to traverse one or more insecure directories to get there:"
159 , T.unlines $ fmap (T.pack . normalise) insecurePaths
160 , "Please ensure that all of these directories are owned by you, not world-writable, and no more group-writable than permitted by your umask."
164-- | Get the umask for the Obelisk process.
167-- http://man7.org/linux/man-pages/man2/umask.2.html#NOTES we have to set the
168-- umask to read it. We are using 'withMVarMasked' to guarantee that setting and
169-- reading isn't interrupted by any exception or interleaved with another thread.
170getUmask :: IO FileMode
171getUmask = withMVarMasked globalUmaskLock $ \() -> do
172 initialMask <- setFileCreationMask safeUmask
173 void (setFileCreationMask initialMask)
176 safeUmask :: FileMode
177 safeUmask = CMode 0o22
179{-# NOINLINE globalUmaskLock #-}
180globalUmaskLock :: MVar ()
181globalUmaskLock = unsafePerformIO (newMVar ())
183-- | Get the FilePath to the containing project directory, if there is one
184findProjectRoot :: MonadObelisk m => FilePath -> m (Maybe FilePath)
185findProjectRoot target = do
186 myUid <- liftIO getRealUserID
187 targetStat <- liftIO $ getFileStatus target
188 umask <- liftIO getUmask
189 (result, _) <- liftIO $ runStateT (walkToProjectRoot target targetStat umask myUid) []
190 return $ makeRelative "." <$> result
192withProjectRoot :: MonadObelisk m => FilePath -> (FilePath -> m a) -> m a
193withProjectRoot target f = findProjectRoot target >>= \case
194 Nothing -> failWith "Must be used inside of an Obelisk project"
197-- | Walk from the current directory to the containing project's root directory,
198-- if there is one, accumulating potentially insecure directories that were
199-- traversed in the process. Return the project root directory, if found.
201 :: (MonadState [FilePath] m, MonadIO m)
202 => FilePath -> FileStatus -> FileMode -> UserID -> m (Maybe FilePath)
203walkToProjectRoot this thisStat desiredUmask myUid = liftIO (doesDirectoryExist this) >>= \case
204 -- It's not a directory, so it can't be a project
206 let dir = takeDirectory this
207 dirStat <- liftIO $ getFileStatus dir
208 walkToProjectRoot dir dirStat desiredUmask myUid
210 when (not $ isWellOwnedAndWellPermissioned thisStat myUid desiredUmask) $ modify (this:)
211 liftIO (doesDirectoryExist $ toObeliskDir this) >>= \case
212 True -> return $ Just this
214 let next = this </> ".." -- Use ".." instead of chopping off path segments, so that if the current directory is moved during the traversal, the traversal stays consistent
215 nextStat <- liftIO $ getFileStatus next
216 let fileIdentity fs = (deviceID fs, fileID fs)
217 isSameFileAs = (==) `on` fileIdentity
218 if thisStat `isSameFileAs` nextStat
219 then return Nothing -- Found a cycle; probably hit root directory
220 else walkToProjectRoot next nextStat desiredUmask myUid
222-- | Walk from the given project root directory to its Obelisk implementation
223-- directory, accumulating potentially insecure directories that were traversed
225walkToImplDir :: (MonadState [FilePath] m, MonadIO m) => FilePath -> UserID -> FileMode -> m ()
226walkToImplDir projectRoot myUid umask = do
227 let obDir = toObeliskDir projectRoot
228 obDirStat <- liftIO $ getFileStatus obDir
229 when (not $ isWellOwnedAndWellPermissioned obDirStat myUid umask) $ modify (obDir:)
230 let implThunk = obDir </> "impl"
231 implThunkStat <- liftIO $ getFileStatus implThunk
232 when (not $ isWellOwnedAndWellPermissioned implThunkStat myUid umask) $ modify (implThunk:)
234-- | Check to see if directory is writable by a user whose User ID matches the
235-- second argument provided, and if the fact that other people can write to that
236-- directory is in accordance with the umask of the system, passed as the third
238isWellOwnedAndWellPermissioned :: FileStatus -> UserID -> FileMode -> Bool
239isWellOwnedAndWellPermissioned s uid umask = isOwnedBy s uid && filePermissionIsSafe s umask
241isOwnedBy :: FileStatus -> UserID -> Bool
242isOwnedBy s uid = fileOwner s == uid
244-- | Check to see if a directory respect the umask, but check explicitly that
245-- it's not world writable in any case.
246filePermissionIsSafe :: FileStatus -> FileMode -> Bool
247filePermissionIsSafe s umask = not fileWorldWritable && fileGroupWritable <= umaskGroupWritable
249 fileWorldWritable = fileMode s .&. 0o002 == 0o002
250 fileGroupWritable = fileMode s .&. 0o020 == 0o020
251 umaskGroupWritable = umask .&. 0o020 == 0
253-- | Nix syntax requires relative paths to be prefixed by @./@ or
254-- @../@. This will make a 'FilePath' that can be embedded in a Nix
256toNixPath :: FilePath -> FilePath
257toNixPath root | "/" `isInfixOf` root = root
258 | otherwise = "./" <> root
260nixShellRunConfig :: MonadObelisk m => FilePath -> Bool -> Maybe String -> m NixShellConfig
261nixShellRunConfig root isPure command = do
262 nixpkgsPath <- fmap T.strip $ readProcessAndLogStderr Debug $ setCwd (Just root) $
263 proc nixExePath ["eval", "(import .obelisk/impl {}).nixpkgs.path"]
264 nixRemote <- liftIO $ lookupEnv "NIX_REMOTE"
266 & nixShellConfig_pure .~ isPure
267 & nixShellConfig_common . nixCmdConfig_target .~ (def & target_path .~ Nothing)
268 & nixShellConfig_run .~ (command <&> \c -> mconcat
269 [ "export NIX_PATH=nixpkgs=", T.unpack nixpkgsPath, "; "
270 , maybe "" (\v -> "export NIX_REMOTE=" <> v <> "; ") nixRemote
274nixShellRunProc :: NixShellConfig -> ProcessSpec
275nixShellRunProc cfg = setDelegateCtlc True $ proc "nix-shell" $ runNixShellConfig cfg
277nixShellWithPkgs :: MonadObelisk m => FilePath -> Bool -> Bool -> Map Text FilePath -> Maybe String -> m ()
278nixShellWithPkgs root isPure chdirToRoot packageNamesAndPaths command = do
279 packageNamesAndAbsPaths <- liftIO $ for packageNamesAndPaths makeAbsolute
280 defShellConfig <- nixShellRunConfig root isPure command
281 let setCwd_ = if chdirToRoot then setCwd (Just root) else id
282 (_, _, _, ph) <- createProcess_ "nixShellWithPkgs" $ setCwd_ $ nixShellRunProc $ defShellConfig
283 & nixShellConfig_common . nixCmdConfig_target . target_expr ?~
284 "{root, pkgs}: ((import root {}).passthru.__unstable__.self.extend (_: _: {\
285 \shellPackages = builtins.fromJSON pkgs;\
286 \})).project.shells.ghc"
287 & nixShellConfig_common . nixCmdConfig_args .~
288 [ rawArg "root" $ toNixPath $ if chdirToRoot then "." else root
289 , strArg "pkgs" (T.unpack $ decodeUtf8 $ BSL.toStrict $ Json.encode packageNamesAndAbsPaths)
291 void $ liftIO $ waitForProcess ph
293nixShellWithHoogle :: MonadObelisk m => FilePath -> Bool -> String -> Maybe String -> m ()
294nixShellWithHoogle root isPure shell' command = do
295 defShellConfig <- nixShellRunConfig root isPure command
296 (_, _, _, ph) <- createProcess_ "nixShellWithHoogle" $ setCwd (Just root) $ nixShellRunProc $ defShellConfig
297 & nixShellConfig_common . nixCmdConfig_target . target_expr ?~
298 "{shell}: ((import ./. {}).passthru.__unstable__.self.extend (_: super: {\
299 \userSettings = super.userSettings // { withHoogle = true; };\
300 \})).project.shells.${shell}"
301 & nixShellConfig_common . nixCmdConfig_args .~ [ strArg "shell" shell' ]
302 void $ liftIO $ waitForProcess ph
304projectShell :: MonadObelisk m => FilePath -> Bool -> String -> Maybe String -> m ()
305projectShell root isPure shellName command = do
306 defShellConfig <- nixShellRunConfig root isPure command
307 (_, _, _, ph) <- createProcess_ "runNixShellAttr" $ setCwd (Just root) $ nixShellRunProc $ defShellConfig
308 & nixShellConfig_common . nixCmdConfig_target . target_path ?~ "default.nix"
309 & nixShellConfig_common . nixCmdConfig_target . target_attr ?~ ("shells." <> shellName)
310 void $ liftIO $ waitForProcess ph
312findProjectAssets :: MonadObelisk m => FilePath -> m Text
313findProjectAssets root = do
314 isDerivation <- readProcessAndLogStderr Debug $ setCwd (Just root) $
317 , "(let a = import ./. {}; in toString (a.reflex.nixpkgs.lib.isDerivation a.passthru.staticFilesImpure))"
319 -- `--raw` is not available with old nix-instantiate. It drops quotation
320 -- marks and trailing newline, so is very convenient for shelling out.
322 -- Check whether the impure static files are a derivation (and so must be built)
323 if isDerivation == "1"
324 then fmap T.strip $ readProcessAndLogStderr Debug $ setCwd (Just root) $ -- Strip whitespace here because nix-build has no --raw option
327 , "-E", "(import ./. {}).passthru.staticFilesImpure"
329 else readProcessAndLogStderr Debug $ setCwd (Just root) $
330 proc nixExePath ["eval", "-f", ".", "passthru.staticFilesImpure", "--raw"]
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list