packages feed

nixpkgs-update-0.3.0: src/Rewrite.hs

{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}

module Rewrite
  ( Args (..),
    runAll,
    golangModuleVersion,
    quotedUrls,
    quotedUrlsET,
    rustCrateVersion,
    version,
    redirectedUrls,
  )
where

import qualified Data.Text as T
import Data.Text.Encoding as T
import Data.Text.Encoding.Error as T
import Data.Text.IO as T
import qualified File
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types.Status (statusCode)
import qualified Nix
import OurPrelude
import qualified Polysemy.Error as Error
import Polysemy.Output (Output, output)
import qualified Process
import System.Exit
import Utils (UpdateEnv (..))
import qualified Utils
  ( runLog,
    stripQuotes,
  )
import Prelude hiding (log)

{-
 This module contains rewrite functions that make some modification to the
 nix derivation. These are in the IO monad so that they can do things like
 re-run nix-build to recompute hashes, but morally they should just stick to
 editing the derivationFile for their one stated purpose.

 The return contract is:
 - If it makes a modification, it should return a simple message to attach to
   the pull request description to provide context or justification for code
   reviewers (e.g., a GitHub issue or RFC).
 - If it makes no modification, return None
 - If it throws an exception, nixpkgs-update will be aborted for the package and
   no other rewrite functions will run.

  TODO: Setup some unit tests for these!
-}
data Args = Args
  { updateEnv :: Utils.UpdateEnv,
    attrPath :: Text,
    derivationFile :: FilePath,
    derivationContents :: Text,
    hasUpdateScript :: Bool
  }

type Rewriter = (Text -> IO ()) -> Args -> ExceptT Text IO (Maybe Text)

type Plan = [(Text, Rewriter)]

plan :: Plan
plan =
  [ ("version", version),
    ("rustCrateVersion", rustCrateVersion),
    ("golangModuleVersion", golangModuleVersion),
    ("updateScript", updateScript),
    ("", quotedUrlsET) -- Don't change the logger
    --("redirectedUrl", Rewrite.redirectedUrls)
  ]

runAll :: (Text -> IO ()) -> Args -> ExceptT Text IO [Text]
runAll log args = do
  msgs <- forM plan $ \(name, f) -> do
    let log' msg =
          if T.null name
            then log msg
            else log $ ("[" <> name <> "] ") <> msg
    lift $ log' "" -- Print initial empty message to signal start of rewriter
    f log' args
  return $ catMaybes msgs

--------------------------------------------------------------------------------
-- The canonical updater: updates the src attribute and recomputes the sha256
version :: MonadIO m => (Text -> m ()) -> Args -> ExceptT Text m (Maybe Text)
version log args@Args {..} = do
  if
      | Nix.numberOfFetchers derivationContents > 1 || Nix.numberOfHashes derivationContents > 1 -> do
        lift $ log "generic version rewriter does not support multiple hashes"
        return Nothing
      | hasUpdateScript -> do
        lift $ log "skipping because derivation has updateScript"
        return Nothing
      | otherwise -> do
        srcVersionFix args
        lift $ log "updated version and sha256"
        return $ Just "Version update"

--------------------------------------------------------------------------------
-- Rewrite meta.homepage (and eventually other URLs) to be quoted if not
-- already, as per https://github.com/NixOS/rfcs/pull/45
quotedUrls ::
  Members '[Process.Process, File.File, Error Text, Output Text] r =>
  Args ->
  Sem r (Maybe Text)
quotedUrls Args {..} = do
  output "[quotedUrls]"
  homepage <- Nix.getHomepage attrPath
  stripped <- case Utils.stripQuotes homepage of
    Nothing -> throw "Could not strip url! This should never happen!"
    Just x -> pure x
  let goodHomepage = "homepage = " <> homepage <> ";"
  let replacer = \target -> File.replace target goodHomepage derivationFile
  urlReplaced1 <- replacer ("homepage = " <> stripped <> ";")
  urlReplaced2 <- replacer ("homepage = " <> stripped <> " ;")
  urlReplaced3 <- replacer ("homepage =" <> stripped <> ";")
  urlReplaced4 <- replacer ("homepage =" <> stripped <> "; ")
  if urlReplaced1 || urlReplaced2 || urlReplaced3 || urlReplaced4
    then do
      output "[quotedUrls]: added quotes to meta.homepage"
      return $ Just "Quoted meta.homepage for [RFC 45](https://github.com/NixOS/rfcs/pull/45)"
    else do
      output "[quotedUrls] nothing found to replace"
      return Nothing

quotedUrlsET :: MonadIO m => (Text -> IO ()) -> Args -> ExceptT Text m (Maybe Text)
quotedUrlsET log rwArgs =
  ExceptT $
    liftIO
      . runFinal
      . embedToFinal
      . Error.runError
      . Process.runIO
      . File.runIO
      . Utils.runLog log
      $ quotedUrls rwArgs

--------------------------------------------------------------------------------
-- Redirect homepage when moved.
redirectedUrls :: MonadIO m => (Text -> m ()) -> Args -> ExceptT Text m (Maybe Text)
redirectedUrls log Args {..} = do
  unstripped <- Nix.getHomepageET attrPath
  homepage <- case Utils.stripQuotes unstripped of
    Nothing -> throwE "Could not strip homepage! This should never happen!"
    Just x -> pure x
  response <- liftIO $ do
    manager <- HTTP.newManager HTTP.defaultManagerSettings
    request <- HTTP.parseRequest (T.unpack homepage)
    HTTP.httpLbs request manager
  let status = statusCode $ HTTP.responseStatus response
  if status `elem` [301, 308]
    then do
      lift $ log "Redirecting URL"
      let headers = HTTP.responseHeaders response
          location = lookup "Location" headers
      case location of
        Nothing -> do
          lift $ log "Server did not return a location"
          return Nothing
        Just ((T.decodeUtf8With T.lenientDecode) -> newHomepage) -> do
          _ <- File.replaceIO homepage newHomepage derivationFile
          lift $ log "Replaced homepage"
          return $
            Just $
              "Replaced homepage by "
                <> newHomepage
                <> " due http "
                <> (T.pack . show) status
    else do
      lift $ log "URL not redirected"
      return Nothing

--------------------------------------------------------------------------------
-- Rewrite Rust on rustPlatform.buildRustPackage
-- This is basically `version` above, but with a second pass to also update the
-- cargoSha256 vendor hash.
rustCrateVersion :: MonadIO m => (Text -> m ()) -> Args -> ExceptT Text m (Maybe Text)
rustCrateVersion log args@Args {..} = do
  if
      | not (T.isInfixOf "cargoSha256" derivationContents) -> do
        lift $ log "No cargoSha256 found"
        return Nothing
      | hasUpdateScript -> do
        lift $ log "skipping because derivation has updateScript"
        return Nothing
      | otherwise -> do
        -- This starts the same way `version` does, minus the assert
        srcVersionFix args
        -- But then from there we need to do this a second time for the cargoSha256!
        oldCargoSha256 <- Nix.getAttr Nix.Raw "cargoSha256" attrPath
        _ <- lift $ File.replaceIO oldCargoSha256 Nix.sha256Zero derivationFile
        newCargoSha256 <- Nix.getHashFromBuild attrPath
        when (oldCargoSha256 == newCargoSha256) $ throwE "cargoSha256 hashes equal; no update necessary"
        lift . log $ "Replacing cargoSha256 with " <> newCargoSha256
        _ <- lift $ File.replaceIO Nix.sha256Zero newCargoSha256 derivationFile
        -- Ensure the package actually builds and passes its tests
        Nix.build attrPath
        lift $ log "Finished updating Crate version and replacing hashes"
        return $ Just "Rust version update"

--------------------------------------------------------------------------------
-- Rewrite Golang packages with buildGoModule
-- This is basically `version` above, but with a second pass to also update the
-- vendorSha256 go vendor hash.
golangModuleVersion :: MonadIO m => (Text -> m ()) -> Args -> ExceptT Text m (Maybe Text)
golangModuleVersion log args@Args {..} = do
  if
      | not (T.isInfixOf "buildGoModule" derivationContents && T.isInfixOf "vendorSha256" derivationContents) -> do
        lift $ log "Not a buildGoModule package with vendorSha256"
        return Nothing
      | hasUpdateScript -> do
        lift $ log "skipping because derivation has updateScript"
        return Nothing
      | otherwise -> do
        -- This starts the same way `version` does, minus the assert
        srcVersionFix args
        -- But then from there we need to do this a second time for the vendorSha256!
        -- Note that explicit `null` cannot be coerced to a string by nix eval --raw
        oldVendorSha256 <- (Nix.getAttr Nix.Raw "vendorSha256" attrPath <|> Nix.getAttr Nix.NoRaw "vendorSha256" attrPath)
        lift . log $ "Found old vendorSha256 = " <> oldVendorSha256
        original <- liftIO $ T.readFile derivationFile
        _ <- lift $ File.replaceIO ("\"" <> oldVendorSha256 <> "\"") "null" derivationFile
        ok <- runExceptT $ Nix.build attrPath
        _ <-
          if isLeft ok
            then do
              _ <- liftIO $ T.writeFile derivationFile original
              _ <- lift $ File.replaceIO oldVendorSha256 Nix.sha256Zero derivationFile
              newVendorSha256 <- Nix.getHashFromBuild attrPath
              _ <- lift $ File.replaceIO Nix.sha256Zero newVendorSha256 derivationFile
              -- Note that on some small bumps, this may not actually change if go.sum did not
              lift . log $ "Replaced vendorSha256 with " <> newVendorSha256
            else do
              lift . log $ "Set vendorSha256 to null"
        -- Ensure the package actually builds and passes its tests
        Nix.build attrPath
        lift $ log "Finished updating vendorSha256"
        return $ Just "Golang update"

--------------------------------------------------------------------------------
-- Calls passthru.updateScript
updateScript :: MonadIO m => (Text -> m ()) -> Args -> ExceptT Text m (Maybe Text)
updateScript log Args {..} = do
  if hasUpdateScript
    then do
      (exitCode, msg) <- Nix.runUpdateScript attrPath
      case exitCode of
        ExitSuccess -> do
          lift $ log "Success"
          lift $ log msg
          return $ Just "Ran passthru.UpdateScript"
        ExitFailure num -> do
          throwE $ "[updateScript] Failed with exit code " <> tshow num <> "\n" <> msg
    else do
      lift $ log "skipping because derivation has no updateScript"
      return Nothing
--------------------------------------------------------------------------------
-- Common helper functions and utilities
-- Helper to update version and src attributes, re-computing the sha256.
-- This is done by the generic version upgrader, but is also a sub-component of some of the others.
srcVersionFix :: MonadIO m => Args -> ExceptT Text m ()
srcVersionFix Args {..} = do
  let UpdateEnv {..} = updateEnv
  oldHash <- Nix.getOldHash attrPath
  _ <- lift $ File.replaceIO oldVersion newVersion derivationFile
  _ <- lift $ File.replaceIO oldHash Nix.sha256Zero derivationFile
  newHash <- Nix.getHashFromBuild attrPath
  when (oldHash == newHash) $ throwE "Hashes equal; no update necessary"
  _ <- lift $ File.replaceIO Nix.sha256Zero newHash derivationFile
  return ()