packages feed

okf-core-0.5.0.0: src/Okf/Path.hs

-- | The OKF v0.2 path-valued field grammar (specification §6.2).
--
-- Several frontmatter fields name a path or URI: @resource@,
-- @sources[].resource@, and the attested-computation fields @computation@,
-- @executor.resource@, and @attester.resource@. Each accepts an absolute URL, a
-- bundle-relative path beginning with @\/@, or an ordinary relative path.
--
-- Classification is total and offline: it never touches the filesystem and never
-- decides whether a target exists. Deciding that is the caller's job, because
-- what counts as existing depends on what the caller can see. 'Okf.Profile'
-- resolves only @.md@ targets, because it is handed a list of concepts and
-- nothing else.
--
-- Deliberately distinct from 'Okf.Graph.extractConceptLinks', which reads
-- Markdown links out of a concept /body/ and is free to drop anything it does
-- not recognize. A path-valued /field/ is something the author wrote on purpose,
-- so every shape it can take is named here rather than silently ignored.
module Okf.Path
  ( PathReference (..),
    classifyPathReference,
    collapseBundlePath,
    PathResolution (..),
    resolvePathReference,
  )
where

import Control.Monad (foldM)
import Data.Text qualified as Text
import Network.URI (parseURI, uriScheme)
import Okf.ConceptId (ConceptId, conceptIdToFilePath)
import Okf.Prelude
import System.FilePath ((</>))
import System.FilePath qualified as FilePath

-- | What one raw path-valued frontmatter value turned out to be.
data PathReference
  = -- | An absolute URL, carrying its case-folded scheme.
    ExternalUrl !Text
  | -- | A path inside the bundle, collapsed and expressed relative to the
    -- bundle root, with any fragment or query suffix removed.
    BundlePath !FilePath
  | -- | A relative path that climbs above the bundle root.
    EscapesBundle
  | -- | Text that is neither: empty, whitespace, or otherwise unusable.
    MalformedPath
  deriving stock (Generic, Eq, Ord, Show)

-- | Classify one raw value written on the given concept. A relative path is
-- resolved against the concept's own directory, matching how a Markdown link in
-- that concept's body resolves; a value beginning with @\/@ resolves from the
-- bundle root regardless of where the concept lives.
--
-- An absolute URL is recognized by having a URI scheme, so this accepts every
-- scheme rather than the three 'Okf.Graph' treats as external. Whether a given
-- scheme is /permitted/ is a profile question and is decided by the caller.
classifyPathReference :: ConceptId -> Text -> PathReference
classifyPathReference sourceConcept rawValue
  | Text.null trimmed = MalformedPath
  | Just scheme <- absoluteUrlScheme trimmed = ExternalUrl scheme
  | Text.null cleanText = MalformedPath
  | otherwise =
      case collapseBundlePath candidatePath of
        Nothing -> EscapesBundle
        Just [] -> MalformedPath
        Just collapsed -> BundlePath collapsed
  where
    trimmed = Text.strip rawValue
    cleanText = stripUrlSuffix trimmed
    cleanPath = Text.unpack cleanText
    sourceDirectory = FilePath.takeDirectory (conceptIdToFilePath sourceConcept)
    candidatePath
      | "/" `Text.isPrefixOf` cleanText = dropWhile (== '/') cleanPath
      | otherwise = sourceDirectory </> cleanPath

-- | The outcome of resolving a path-valued frontmatter field against a bundle:
-- 'classifyPathReference' plus the one question it deliberately does not answer.
data PathResolution
  = -- | An absolute URL, carrying its case-folded scheme. okf never fetches it,
    -- so this is as resolved as an external target gets.
    ResolvedExternal !Text
  | -- | Names a file the bundle contains, at the given bundle-relative path.
    ResolvedInBundle !FilePath
  | -- | Looks exactly like a bundle path, and the bundle holds no such file.
    DanglingInBundle !FilePath
  | -- | Climbs above the bundle root, so there is nothing in the bundle it
    -- could name.
    UnresolvableEscape
  | -- | Empty, whitespace, or otherwise unusable as either a URL or a path.
    UnresolvableMalformed
  deriving stock (Generic, Eq, Ord, Show)

-- | Resolve one raw path-valued value written on the given concept, asking the
-- supplied predicate whether a bundle-relative path names a file that exists.
--
-- The predicate is a plain function rather than a bundle type so that this
-- module stays below 'Okf.Bundle' in the import graph; a caller holding a
-- 'Okf.Bundle.BundleInventory' passes
-- @flip Okf.Bundle.bundleInventoryMember inventory@.
--
-- Deliberately a thin composition over 'classifyPathReference'. Its value is
-- that the five outcomes are named once, so every caller agrees on what they
-- mean — in particular that an external URL is /resolved/ rather than skipped,
-- and that a path which escapes the bundle is a different finding from one that
-- simply is not there.
resolvePathReference :: (FilePath -> Bool) -> ConceptId -> Text -> PathResolution
resolvePathReference exists sourceConcept rawValue =
  case classifyPathReference sourceConcept rawValue of
    ExternalUrl scheme -> ResolvedExternal scheme
    BundlePath target
      | exists target -> ResolvedInBundle target
      | otherwise -> DanglingInBundle target
    EscapesBundle -> UnresolvableEscape
    MalformedPath -> UnresolvableMalformed

-- | The case-folded scheme of an absolute URL, or 'Nothing' for anything that
-- is not one. A bundle-absolute path such as @\/references\/policy.md@ has no
-- scheme and so is never mistaken for a URL.
absoluteUrlScheme :: Text -> Maybe Text
absoluteUrlScheme rawValue = do
  parsed <- parseURI (Text.unpack rawValue)
  let scheme = Text.toCaseFold (Text.dropWhileEnd (== ':') (Text.pack (uriScheme parsed)))
  if Text.null scheme then Nothing else Just scheme

-- | Fold @.@ and @..@ segments, returning 'Nothing' for a path that climbs above
-- the bundle root. Exported because resolving a bundle-relative path is the same
-- operation wherever it is done, and a second copy would be free to drift.
collapseBundlePath :: FilePath -> Maybe FilePath
collapseBundlePath =
  fmap FilePath.joinPath . foldM step [] . FilePath.splitDirectories
  where
    step [] "." = Just []
    step acc "." = Just acc
    step [] ".." = Nothing
    step acc ".." = Just (init acc)
    step acc segment = Just (acc <> [segment])

-- | Drop a URL fragment or query suffix, so @policy.md#section@ resolves to the
-- same target as @policy.md@.
stripUrlSuffix :: Text -> Text
stripUrlSuffix =
  Text.takeWhile (\character -> character /= '#' && character /= '?')