diff --git a/Setup.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/System/FilePath.hs b/System/FilePath.hs
new file mode 100644
--- /dev/null
+++ b/System/FilePath.hs
@@ -0,0 +1,210 @@
+-----------------------------------------------------------------------------
+-- |
+-- Module: System.FilePath
+-- Copyright: 2010 John Millikin
+-- License: MIT
+--
+-- Maintainer:  jmillikin@gmail.com
+-- Portability:  portable
+--
+-----------------------------------------------------------------------------
+
+module System.FilePath
+	( FilePath
+	, empty
+	
+	-- * Basic properties
+	, null
+	, root
+	, directory
+	, parent
+	, filename
+	, basename
+	, absolute
+	, relative
+	
+	-- * Basic operations
+	, append
+	, (</>)
+	, concat
+	, commonPrefix
+	
+	-- * Extensions
+	, extension
+	, extensions
+	, hasExtension
+	
+	, addExtension
+	, (<.>)
+	, dropExtension
+	, replaceExtension
+	
+	, addExtensions
+	, dropExtensions
+	, replaceExtensions
+	
+	, splitExtension
+	, splitExtensions
+	) where
+
+import Prelude hiding (FilePath, concat, null)
+import qualified Prelude as P
+import Data.Maybe (isNothing, listToMaybe)
+import qualified Data.Monoid as M
+import System.FilePath.Internal
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Char8 as B8
+
+instance M.Monoid FilePath where
+	mempty = empty
+	mappend = append
+	mconcat = concat
+
+-------------------------------------------------------------------------------
+-- Basic properties
+-------------------------------------------------------------------------------
+
+null :: FilePath -> Bool
+null = (== empty)
+
+root :: FilePath -> FilePath
+root p = empty { pathRoot = pathRoot p }
+
+directory :: FilePath -> FilePath
+directory p = empty
+	{ pathRoot = pathRoot p
+	, pathComponents = if P.null (pathComponents p) && isNothing (pathRoot p)
+		then [B8.pack "."]
+		else pathComponents p
+	}
+
+parent :: FilePath -> FilePath
+parent p = empty
+	{ pathRoot = pathRoot p
+	, pathComponents = if P.null (pathComponents p) && isNothing (pathRoot p)
+		then [B8.pack "."]
+		else if null (filename p)
+			then safeInit (pathComponents p)
+			else pathComponents p
+	}
+
+filename :: FilePath -> FilePath
+filename p = empty
+	{ pathBasename = pathBasename p
+	, pathExtensions = pathExtensions p
+	}
+
+basename :: FilePath -> FilePath
+basename p = empty
+	{ pathBasename = pathBasename p
+	}
+
+absolute :: FilePath -> Bool
+absolute p = case pathRoot p of
+	Just RootPosix -> True
+	Just (RootWindowsVolume _) -> True
+	_ -> False
+
+relative :: FilePath -> Bool
+relative p = case pathRoot p of
+	Just _ -> False
+	_ -> True
+
+-------------------------------------------------------------------------------
+-- Basic operations
+-------------------------------------------------------------------------------
+
+append :: FilePath -> FilePath -> FilePath
+append x y = if absolute y then y else xy where
+	xy = y
+		{ pathRoot = pathRoot x
+		, pathComponents = components
+		}
+	components = xComponents ++ pathComponents y
+	xComponents = (pathComponents x ++) $ if null (filename x)
+		then []
+		else [B.concat $ [maybe (B8.pack "") id (pathBasename x)] ++ pathExtensions x]
+
+(</>) :: FilePath -> FilePath -> FilePath
+(</>) = append
+
+concat :: [FilePath] -> FilePath
+concat [] = empty
+concat ps = foldr1 append ps
+
+commonPrefix :: [FilePath] -> FilePath
+commonPrefix [] = empty
+commonPrefix ps = foldr1 step ps where
+	step x y = if pathRoot x /= pathRoot y
+		then empty
+		else let cs = commonComponents x y in
+			if cs /= pathComponents x
+				then empty { pathRoot = pathRoot x, pathComponents = cs }
+				else if pathBasename x /= pathBasename y
+					then empty { pathRoot = pathRoot x, pathComponents = cs }
+					else let exts = commonExtensions x y in
+						x { pathExtensions = exts }
+	
+	commonComponents x y = common (pathComponents x) (pathComponents y)
+	commonExtensions x y = common (pathExtensions x) (pathExtensions y)
+	
+	common [] _ = []
+	common _ [] = []
+	common (x:xs) (y:ys) = if x == y
+		then x : common xs ys
+		else []
+
+-------------------------------------------------------------------------------
+-- Basenames
+-------------------------------------------------------------------------------
+
+-------------------------------------------------------------------------------
+-- Extensions
+-------------------------------------------------------------------------------
+
+extension :: FilePath -> Maybe B.ByteString
+extension p = case extensions p of
+	[] -> Nothing
+	es -> Just (last es)
+
+extensions :: FilePath -> [B.ByteString]
+extensions = pathExtensions
+
+hasExtension :: FilePath -> B.ByteString -> Bool
+hasExtension p e = extension p == Just e
+
+addExtension :: FilePath -> B.ByteString -> FilePath
+addExtension p ext = addExtensions p [ext]
+
+addExtensions :: FilePath -> [B.ByteString] -> FilePath
+addExtensions p exts = p { pathExtensions = pathExtensions p ++ exts }
+
+(<.>) :: FilePath -> B.ByteString -> FilePath
+(<.>) = addExtension
+
+dropExtension :: FilePath -> FilePath
+dropExtension p = p { pathExtensions = safeInit (pathExtensions p) }
+
+dropExtensions :: FilePath -> FilePath
+dropExtensions p = p { pathExtensions = [] }
+
+replaceExtension :: FilePath -> B.ByteString -> FilePath
+replaceExtension = addExtension . dropExtension
+
+replaceExtensions :: FilePath -> [B.ByteString] -> FilePath
+replaceExtensions = addExtensions . dropExtensions
+
+splitExtension :: FilePath -> (FilePath, Maybe B.ByteString)
+splitExtension p = (dropExtension p, extension p)
+
+splitExtensions :: FilePath -> (FilePath, [B.ByteString])
+splitExtensions p = (dropExtensions p, extensions p)
+
+-------------------------------------------------------------------------------
+-- Utils
+-------------------------------------------------------------------------------
+
+safeInit :: [a] -> [a]
+safeInit xs = case xs of
+	[] -> []
+	_ -> init xs
diff --git a/System/FilePath/CurrentOS.hs b/System/FilePath/CurrentOS.hs
new file mode 100644
--- /dev/null
+++ b/System/FilePath/CurrentOS.hs
@@ -0,0 +1,85 @@
+-----------------------------------------------------------------------------
+-- |
+-- Module: System.FilePath.CurrentOS
+-- Copyright: 2010 John Millikin
+-- License: MIT
+--
+-- Maintainer:  jmillikin@gmail.com
+-- Portability:  portable
+--
+-- Re-exports contents of "System.FilePath", defaulting to the current OS's
+-- rules when needed.
+--
+-----------------------------------------------------------------------------
+
+{-# LANGUAGE CPP #-}
+module System.FilePath.CurrentOS
+	( module System.FilePath
+	, currentOS
+	
+	-- * Rule-specific path properties
+	, valid
+	, normalise
+	, equivalent
+	
+	-- * Parsing file paths
+	, toBytes
+	, toLazyBytes
+	, toString
+	, fromBytes
+	, fromLazyBytes
+	, fromString
+	
+	-- * Search paths
+	, splitSearchPath
+	) where
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.String as S
+import System.FilePath
+import qualified System.FilePath as F
+import qualified System.FilePath.Rules as R
+
+currentOS :: R.Rules
+#if defined(mingw32_HOST_OS) || defined(__MINGW32__)
+currentOS = R.windows
+#else
+currentOS = R.posix
+#endif
+
+instance S.IsString F.FilePath where
+	fromString = R.fromString currentOS
+
+instance Show F.FilePath where
+	showsPrec d path = showParen (d > 10) $
+		showString "FilePath " . shows (toBytes path)
+
+valid :: F.FilePath -> Bool
+valid = R.valid currentOS
+
+normalise :: F.FilePath -> F.FilePath
+normalise = R.normalise currentOS
+
+equivalent :: F.FilePath -> F.FilePath -> Bool
+equivalent = R.equivalent currentOS
+
+toBytes :: F.FilePath -> B.ByteString
+toBytes = R.toBytes currentOS
+
+toLazyBytes :: F.FilePath -> BL.ByteString
+toLazyBytes = R.toLazyBytes currentOS
+
+toString :: F.FilePath -> String
+toString = R.toString currentOS
+
+fromBytes :: B.ByteString -> F.FilePath
+fromBytes = R.fromBytes currentOS
+
+fromLazyBytes :: BL.ByteString -> F.FilePath
+fromLazyBytes = R.fromLazyBytes currentOS
+
+fromString :: String -> F.FilePath
+fromString = R.fromString currentOS
+
+splitSearchPath :: B.ByteString -> [F.FilePath]
+splitSearchPath = R.splitSearchPath currentOS
diff --git a/System/FilePath/Internal.hs b/System/FilePath/Internal.hs
new file mode 100644
--- /dev/null
+++ b/System/FilePath/Internal.hs
@@ -0,0 +1,61 @@
+-----------------------------------------------------------------------------
+-- |
+-- Module: System.FilePath.Internal
+-- Copyright: 2010 John Millikin
+-- License: MIT
+--
+-- Maintainer:  jmillikin@gmail.com
+-- Portability:  portable
+--
+-----------------------------------------------------------------------------
+
+{-# LANGUAGE DeriveDataTypeable #-}
+module System.FilePath.Internal where
+
+import Prelude hiding (FilePath)
+import Data.Data (Data)
+import Data.Typeable (Typeable)
+import qualified Data.ByteString as B
+
+-------------------------------------------------------------------------------
+-- File Paths
+-------------------------------------------------------------------------------
+
+type Component = B.ByteString
+type Basename = B.ByteString
+type Extension = B.ByteString
+
+data Root
+	= RootPosix
+	| RootWindowsVolume Char
+	| RootWindowsCurrentVolume
+	deriving (Eq, Data, Typeable)
+
+data FilePath = FilePath
+	{ pathRoot :: (Maybe Root)
+	, pathComponents :: [Component]
+	, pathBasename :: (Maybe Basename)
+	, pathExtensions :: [Extension]
+	}
+	deriving (Eq, Data, Typeable)
+
+empty :: FilePath
+empty = FilePath Nothing [] Nothing []
+
+-------------------------------------------------------------------------------
+-- Rules
+-------------------------------------------------------------------------------
+
+data Rules = Rules
+	{ rulesName :: String
+	, toByteChunks :: FilePath -> [B.ByteString]
+	, fromBytes :: B.ByteString -> FilePath
+	, caseSensitive :: Bool
+	, valid :: FilePath -> Bool
+	, splitSearchPath :: B.ByteString -> [FilePath]
+	, normalise :: FilePath -> FilePath
+	}
+
+instance Show Rules where
+	showsPrec d r = showParen (d > 10) $
+		showString "Rules " . shows (rulesName r)
diff --git a/System/FilePath/Rules.hs b/System/FilePath/Rules.hs
new file mode 100644
--- /dev/null
+++ b/System/FilePath/Rules.hs
@@ -0,0 +1,242 @@
+-----------------------------------------------------------------------------
+-- |
+-- Module: System.FilePath.Rules
+-- Copyright: 2010 John Millikin
+-- License: MIT
+--
+-- Maintainer:  jmillikin@gmail.com
+-- Portability:  portable
+--
+-----------------------------------------------------------------------------
+
+module System.FilePath.Rules
+	( Rules
+	, posix
+	, windows
+	
+	-- * Rule-specific path properties
+	, valid
+	, normalise
+	, equivalent
+	
+	-- * Parsing file paths
+	, toBytes
+	, toLazyBytes
+	, toString
+	, fromBytes
+	, fromLazyBytes
+	, fromString
+	
+	-- * Parsing search paths
+	, splitSearchPath
+	) where
+
+import Prelude hiding (FilePath, null)
+import qualified Prelude as P
+import Data.Char (toUpper, chr)
+import Data.List (intersperse)
+import qualified Data.ByteString as B
+import qualified Data.ByteString.Char8 as B8
+import qualified Data.ByteString.Lazy as BL
+import qualified Data.ByteString.Lazy.Char8 as BL8
+
+import System.FilePath
+import System.FilePath.Internal
+
+-------------------------------------------------------------------------------
+-- Rule-specific path properties
+-------------------------------------------------------------------------------
+equivalent :: Rules -> FilePath -> FilePath -> Bool
+equivalent r x y = n x == n y where
+	n p = if caseSensitive r
+		then normalise r p
+		else casefold (normalise r p)
+	
+	-- TODO: use proper unicode case folding here? I'm not sure there's
+	-- any way to do correct case-insensitive comparison without knowing
+	-- the filename's encoding.
+	casefold p = p
+		{ pathComponents = map upperBytes $ pathComponents p
+		, pathBasename = fmap upperBytes $ pathBasename p
+		, pathExtensions = map upperBytes $ pathExtensions p
+		}
+
+-------------------------------------------------------------------------------
+-- Public helpers
+-------------------------------------------------------------------------------
+toBytes :: Rules -> FilePath -> B.ByteString
+toBytes r = B.concat . toByteChunks r
+
+toLazyBytes :: Rules -> FilePath -> BL.ByteString
+toLazyBytes r = BL.fromChunks . toByteChunks r
+
+fromLazyBytes :: Rules -> BL.ByteString -> FilePath
+fromLazyBytes r = fromBytes r . B.concat . BL.toChunks
+
+toString :: Rules -> FilePath -> String
+toString r = BL8.unpack . toLazyBytes r
+
+fromString :: Rules -> String -> FilePath
+fromString r = fromBytes r . B8.pack
+
+-------------------------------------------------------------------------------
+-- Generic
+-------------------------------------------------------------------------------
+
+rootBytes :: Maybe Root -> B.ByteString
+rootBytes r = B8.pack $ flip (maybe "") r $ \r' -> case r' of
+	RootPosix -> "/"
+	RootWindowsVolume c -> c : ":\\"
+	RootWindowsCurrentVolume -> "\\"
+
+byteComponents :: FilePath -> [B.ByteString]
+byteComponents path = pathComponents path ++ [name] where
+	name = (`B.append` ext) $ case pathBasename path of
+		Nothing -> B.empty
+		Just name' -> name'
+	ext = case pathExtensions path of
+		[] -> B.empty
+		exts -> B.intercalate (B8.pack ".") (B.empty:exts)
+
+upperBytes :: B.ByteString -> B.ByteString
+upperBytes bytes = (`B.map` bytes) $ \b -> if b >= 0x41 && b <= 0x5A
+	then b + 0x20
+	else b
+
+-------------------------------------------------------------------------------
+-- POSIX
+-------------------------------------------------------------------------------
+
+posix :: Rules
+posix = Rules
+	{ rulesName = "POSIX"
+	, toByteChunks = posixToByteChunks
+	, fromBytes = posixFromBytes
+	, caseSensitive = True
+	, valid = posixValid
+	, splitSearchPath = posixSplitSearch
+	, normalise = posixNormalise
+	}
+
+posixToByteChunks :: FilePath -> [B.ByteString]
+posixToByteChunks p = root : chunks where
+	root = rootBytes $ pathRoot p
+	chunks = intersperse (B8.pack "/") $ byteComponents p
+
+posixFromBytes :: B.ByteString -> FilePath
+posixFromBytes bytes = if B.null bytes then empty else path where
+	path = FilePath root cs name exts
+	split' = B.split 0x2F bytes
+	
+	(root, pastRoot) = if B.null (head split')
+		then (Just RootPosix, tail split')
+		else (Nothing, split')
+	
+	cs = if P.null pastRoot
+		then []
+		else filter (not . B.null) $ if B.null (last pastRoot)
+			then pastRoot
+			else init pastRoot
+	
+	(name, exts) = case B.split 0x2E (last split') of
+		[] -> (Nothing, [])
+		(name':exts') -> (Just name', exts')
+
+posixValid :: FilePath -> Bool
+posixValid p = validRoot && validComponents where
+	validComponents = flip all (byteComponents p)
+		$ not . B.any (\b -> b == 0 || b == 0x2F)
+	validRoot = case pathRoot p of
+		Nothing -> True
+		Just RootPosix -> True
+		_ -> False
+
+posixSplitSearch :: B.ByteString -> [FilePath]
+posixSplitSearch = map (posixFromBytes . normSearch) . B.split 0x3A where
+	normSearch bytes = if B.null bytes then B8.pack "." else bytes
+
+posixNormalise :: FilePath -> FilePath
+posixNormalise p = p { pathComponents = components } where
+	components = filter (/= B8.pack ".") $ pathComponents p
+
+-------------------------------------------------------------------------------
+-- Windows
+-------------------------------------------------------------------------------
+
+windows :: Rules
+windows = Rules
+	{ rulesName = "Windows"
+	, toByteChunks = winToByteChunks
+	, fromBytes = winFromBytes
+	, caseSensitive = False
+	, valid = winValid
+	, splitSearchPath = map winFromBytes . filter (not . B.null) . B.split 0x3B
+	, normalise = winNormalise
+	}
+
+winToByteChunks :: FilePath -> [B.ByteString]
+winToByteChunks p = root : chunks where
+	root = rootBytes $ pathRoot p
+	chunks = intersperse (B8.pack "\\") $ byteComponents p
+
+winFromBytes :: B.ByteString -> FilePath
+winFromBytes bytes = if B.null bytes then empty else path where
+	path = FilePath root cs name exts
+	split' = B.splitWith isSep bytes
+	isSep b = b == 0x2F || b == 0x5C
+	
+	(root, pastRoot) = let
+		head' = head split'
+		tail' = tail split'
+		in if B.null head'
+			then (Just RootWindowsCurrentVolume, tail')
+			else if B.elem 0x3A head'
+				then (Just (parseDrive head'), tail')
+				else (Nothing, split')
+	
+	parseDrive bytes' = RootWindowsVolume c where
+		c = chr . fromIntegral . B.head $ bytes'
+	
+	cs = if P.null pastRoot
+		then []
+		else filter (not . B.null) $ if B.null (last pastRoot)
+			then pastRoot
+			else init pastRoot
+	
+	(name, exts) = case B.split 0x2E (last split') of
+		[] -> (Nothing, [])
+		(name':exts') -> (Just name', exts')
+
+winValid :: FilePath -> Bool
+winValid p = validRoot && noReserved && validCharacters where
+	reservedChars = [0..0x1F] ++ [0x2F, 0x5C, 0x3F, 0x2A, 0x3A, 0x7C, 0x22, 0x3C, 0x3E]
+	reservedNames = map B8.pack
+		[ "AUX", "CLOCK$", "COM1", "COM2", "COM3", "COM4"
+		, "COM5", "COM6", "COM7", "COM8", "COM9", "CON"
+		, "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6"
+		, "LPT7", "LPT8", "LPT9", "NUL", "PRN"
+		]
+	
+	validRoot = case pathRoot p of
+		Nothing -> True
+		Just RootWindowsCurrentVolume -> True
+		Just (RootWindowsVolume v) -> elem (toUpper v) ['A'..'Z']
+		_ -> False
+	
+	noExt = p { pathExtensions = [] }
+	noReserved = flip all (byteComponents noExt)
+		$ \c -> notElem (upperBytes c) reservedNames
+	
+	validCharacters = flip all (byteComponents p)
+		$ not . B.any (`elem` reservedChars)
+
+winNormalise :: FilePath -> FilePath
+winNormalise p = p' where
+	p' = p
+		{ pathComponents = components
+		, pathRoot = root
+		}
+	components = filter (/= B8.pack ".") $ pathComponents p
+	root = case pathRoot p of
+		Just (RootWindowsVolume c) -> Just (RootWindowsVolume (toUpper c))
+		r -> r
diff --git a/license.txt b/license.txt
new file mode 100644
--- /dev/null
+++ b/license.txt
@@ -0,0 +1,22 @@
+Copyright (c) 2010 John Millikin
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
diff --git a/system-filepath.cabal b/system-filepath.cabal
new file mode 100644
--- /dev/null
+++ b/system-filepath.cabal
@@ -0,0 +1,34 @@
+name: system-filepath
+version: 0.1
+synopsis: High-level, byte-based file and directory path manipulations
+license: MIT
+license-file: license.txt
+author: John Millikin <jmillikin@gmail.com>
+maintainer: jmillikin@gmail.com
+copyright: Copyright (c) John Millikin 2010
+build-type: Simple
+cabal-version: >=1.6
+category: System
+stability: experimental
+homepage: http://ianen.org/haskell/system-filepath/
+bug-reports: mailto:jmillikin@gmail.com
+tested-with: GHC==6.12.1
+
+source-repository head
+  type: darcs
+  location: http://ianen.org/haskell/system-filepath/
+
+library
+  ghc-options: -Wall -fno-warn-unused-do-bind -fno-warn-orphans
+
+  build-depends:
+      base >=3 && < 5
+    , bytestring >= 0.9 && < 0.10
+
+  exposed-modules:
+    System.FilePath
+    System.FilePath.CurrentOS
+    System.FilePath.Rules
+
+  other-modules:
+    System.FilePath.Internal
