diff --git a/codebase2/codebase-sqlite/U/Codebase/Sqlite/DbId.hs b/codebase2/codebase-sqlite/U/Codebase/Sqlite/DbId.hs index 1cb0d95f5f..a1cb6d9d55 100644 --- a/codebase2/codebase-sqlite/U/Codebase/Sqlite/DbId.hs +++ b/codebase2/codebase-sqlite/U/Codebase/Sqlite/DbId.hs @@ -39,6 +39,14 @@ newtype CausalHashId = CausalHashId {unCausalHashId :: HashId} deriving (Eq, Ord) deriving (Num, Real, Enum, Integral, Bits, FromField, ToField) via HashId +newtype CommentHashId = CommentHashId {unCommentHashId :: HashId} + deriving (Eq, Ord) + deriving (Num, Real, Enum, Integral, Bits, FromField, ToField) via HashId + +newtype CommentRevisionHashId = CommentRevisionHashId {unCommentRevisionHashId :: HashId} + deriving (Eq, Ord) + deriving (Num, Real, Enum, Integral, Bits, FromField, ToField) via HashId + newtype ProjectBranchId = ProjectBranchId {unProjectBranchId :: UUID} deriving newtype (Eq, FromField, Ord, Show, ToField) @@ -67,6 +75,20 @@ instance Show BranchHashId where instance Show CausalHashId where show h = "CausalHashId (" ++ show (unCausalHashId h) ++ ")" +instance Show CommentHashId where + show h = "CommentHashId (" ++ show (unCommentHashId h) ++ ")" + +instance Show CommentRevisionHashId where + show h = "CommentRevisionHashId (" ++ show (unCommentRevisionHashId h) ++ ")" + newtype HistoryCommentId = HistoryCommentId Word64 deriving (Eq, Ord, Show) deriving (Num, Real, Enum, Integral, Bits, FromField, ToField) via Word64 + +newtype HistoryCommentRevisionId = HistoryCommentRevisionId Word64 + deriving (Eq, Ord, Show) + deriving (Num, Real, Enum, Integral, Bits, FromField, ToField) via Word64 + +newtype KeyThumbprintId = KeyThumbprintId Word64 + deriving (Eq, Ord, Show) + deriving (Num, Real, Enum, Integral, Bits, FromField, ToField) via Word64 diff --git a/codebase2/codebase-sqlite/U/Codebase/Sqlite/HistoryComment.hs b/codebase2/codebase-sqlite/U/Codebase/Sqlite/HistoryComment.hs deleted file mode 100644 index 124b7142e6..0000000000 --- a/codebase2/codebase-sqlite/U/Codebase/Sqlite/HistoryComment.hs +++ /dev/null @@ -1,12 +0,0 @@ -module U.Codebase.Sqlite.HistoryComment (HistoryComment (..)) where - -import Data.Text (Text) - -data HistoryComment causal id = HistoryComment - { author :: Text, - subject :: Text, - content :: Text, - causal :: causal, - commentId :: id - } - deriving (Show, Eq, Functor) diff --git a/codebase2/codebase-sqlite/U/Codebase/Sqlite/Queries.hs b/codebase2/codebase-sqlite/U/Codebase/Sqlite/Queries.hs index f9054dcd1a..b5baf1c1db 100644 --- a/codebase2/codebase-sqlite/U/Codebase/Sqlite/Queries.hs +++ b/codebase2/codebase-sqlite/U/Codebase/Sqlite/Queries.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE NumericUnderscores #-} {-# LANGUAGE TemplateHaskell #-} -- | Some naming conventions used in this module: @@ -35,6 +36,8 @@ module U.Codebase.Sqlite.Queries expectCausalHash, expectBranchHashForCausalHash, saveBranchHash, + saveHistoryCommentHash, + saveHistoryCommentRevisionHash, -- * hash_object table saveHashObject, @@ -260,6 +263,8 @@ module U.Codebase.Sqlite.Queries addDerivedDependentsByDependencyIndex, addUpgradeBranchTable, addHistoryComments, + addHistoryCommentHashing, + historyCommentHashingCleanup, -- ** schema version currentSchemaVersion, @@ -296,6 +301,9 @@ module U.Codebase.Sqlite.Queries getConfigValue, setConfigValue, + -- * Personal Keys + ensurePersonalKeyThumbprintId, + -- * Types TextPathSegments, JsonParseFailure (..), @@ -328,6 +336,7 @@ import Data.Text qualified as Text import Data.Text.Encoding qualified as Text import Data.Text.Lazy qualified as Text.Lazy import Data.Time qualified as Time +import Data.Time.Clock.POSIX qualified as POSIX import Data.Vector qualified as Vector import Network.URI (URI) import U.Codebase.Branch.Type (NamespaceStats (..)) @@ -335,7 +344,13 @@ import U.Codebase.Config (AuthorName, ConfigKey) import U.Codebase.Config qualified as Config import U.Codebase.Decl qualified as C import U.Codebase.Decl qualified as C.Decl -import U.Codebase.HashTags (BranchHash (..), CausalHash (..), PatchHash (..)) +import U.Codebase.HashTags + ( BranchHash (..), + CausalHash (..), + HistoryCommentHash (..), + HistoryCommentRevisionHash (..), + PatchHash (..), + ) import U.Codebase.Reference (Reference' (..)) import U.Codebase.Reference qualified as C (Reference) import U.Codebase.Reference qualified as C.Reference @@ -348,9 +363,13 @@ import U.Codebase.Sqlite.DbId ( BranchHashId (..), BranchObjectId (..), CausalHashId (..), + CommentHashId (..), + CommentRevisionHashId (..), HashId (..), HashVersion, HistoryCommentId, + HistoryCommentRevisionId, + KeyThumbprintId, ObjectId (..), PatchObjectId (..), ProjectBranchId (..), @@ -366,7 +385,6 @@ import U.Codebase.Sqlite.Decode import U.Codebase.Sqlite.Entity (SyncEntity) import U.Codebase.Sqlite.Entity qualified as Entity import U.Codebase.Sqlite.HashHandle (HashHandle (..)) -import U.Codebase.Sqlite.HistoryComment (HistoryComment (..)) import U.Codebase.Sqlite.LocalIds ( LocalDefnId (..), LocalIds, @@ -407,6 +425,12 @@ import Unison.Hash qualified as Hash import Unison.Hash32 (Hash32) import Unison.Hash32 qualified as Hash32 import Unison.Hash32.Orphans.Sqlite () +import Unison.HistoryComment + ( HistoryComment (..), + HistoryCommentRevision (..), + LatestHistoryComment, + ) +import Unison.KeyThumbprint (KeyThumbprint (..)) import Unison.Name (Name) import Unison.Name qualified as Name import Unison.NameSegment.Internal (NameSegment (NameSegment)) @@ -429,7 +453,7 @@ type TextPathSegments = [Text] -- * main squeeze currentSchemaVersion :: SchemaVersion -currentSchemaVersion = 23 +currentSchemaVersion = 26 runCreateSql :: Transaction () runCreateSql = @@ -519,6 +543,14 @@ addHistoryComments :: Transaction () addHistoryComments = executeStatements $(embedProjectStringFile "sql/020-add-history-comments.sql") +addHistoryCommentHashing :: Transaction () +addHistoryCommentHashing = + executeStatements $(embedProjectStringFile "sql/021-hash-history-comments.sql") + +historyCommentHashingCleanup :: Transaction () +historyCommentHashingCleanup = + executeStatements $(embedProjectStringFile "sql/022-hash-history-comments-cleanup.sql") + schemaVersion :: Transaction SchemaVersion schemaVersion = queryOneCol @@ -629,6 +661,12 @@ expectCausalByCausalHash ch = do bhId <- expectCausalValueHashId hId pure (hId, bhId) +saveHistoryCommentHash :: HistoryCommentHash -> Transaction CommentHashId +saveHistoryCommentHash = fmap CommentHashId . saveHashHash . unHistoryCommentHash + +saveHistoryCommentRevisionHash :: HistoryCommentRevisionHash -> Transaction CommentRevisionHashId +saveHistoryCommentRevisionHash = fmap CommentRevisionHashId . saveHashHash . unHistoryCommentRevisionHash + expectHashIdByHash :: Hash -> Transaction HashId expectHashIdByHash = expectHashId . Hash32.fromHash @@ -4123,44 +4161,90 @@ saveSquashResult bhId chId = ON CONFLICT DO NOTHING |] +-- Convert milliseconds since epoch to UTCTime _exactly_. +-- UTCTime has picosecond precision so this is lossless. +millisToUTCTime :: Int64 -> Time.UTCTime +millisToUTCTime ms = + toRational ms + & (/ (1_000 :: Rational)) + & fromRational + & POSIX.posixSecondsToUTCTime + +utcTimeToMillis :: Time.UTCTime -> Int64 +utcTimeToMillis utcTime = + POSIX.utcTimeToPOSIXSeconds utcTime + & toRational + & (* (1_000 :: Rational)) + & round + getLatestCausalComment :: CausalHashId -> - Transaction (Maybe (HistoryComment CausalHashId HistoryCommentId)) + Transaction (Maybe (LatestHistoryComment KeyThumbprintId CausalHash HistoryCommentRevisionId HistoryCommentHash)) getLatestCausalComment causalHashId = - queryMaybeRow @(HistoryCommentId, CausalHashId, Text, Text, Text) + -- FromRow instances cap out at 10-tuples, so we do a cheeky :. trick. + queryMaybeRow @((Hash32, Hash32, Text, KeyThumbprintId, Int64, HistoryCommentRevisionId, Text, Text, Bool) :. (ByteString, Int64)) [sql| - SELECT cc.id, cc.causal_hash_id, cc.author, ccr.subject, ccr.contents + SELECT comment_hash.base32, causal_hash.base32, cc.author, cc.author_thumbprint_id, cc.created_at_ms, ccr.id, ccr.subject, ccr.contents, ccr.hidden, ccr.author_signature, ccr.created_at_ms FROM history_comments AS cc JOIN history_comment_revisions AS ccr ON cc.id = ccr.comment_id + JOIN hash AS comment_hash ON comment_hash.id = cc.comment_hash_id + JOIN hash AS causal_hash ON causal_hash.id = cc.causal_hash_id WHERE cc.causal_hash_id = :causalHashId - ORDER BY ccr.created_at DESC + ORDER BY ccr.created_at_ms DESC LIMIT 1 |] - <&> fmap \(commentId, causal, author, subject, content) -> - HistoryComment {author, subject, content, commentId, causal} - -commentOnCausal :: HistoryComment CausalHashId () -> Transaction () -commentOnCausal HistoryComment {author, content, subject, causal = causalHashId} = do - mayExistingCommentId <- - queryMaybeCol @HistoryCommentId - [sql| + <&> fmap \((commentHash, causalHash, author, authorThumbprint, commentCreatedAtMs, revisionId, subject, content, isHidden) :. (authorSignature, revisionCreatedAtMs)) -> + HistoryCommentRevision + { subject, + content, + createdAt = millisToUTCTime revisionCreatedAtMs, + revisionId, + isHidden, + authorSignature, + comment = + HistoryComment + { author, + authorThumbprint, + causal = CausalHash . Hash32.toHash $ causalHash, + createdAt = millisToUTCTime commentCreatedAtMs, + commentId = HistoryCommentHash . Hash32.toHash $ commentHash + } + } + +commentOnCausal :: LatestHistoryComment KeyThumbprint CausalHashId HistoryCommentRevisionHash HistoryCommentHash -> Transaction () +commentOnCausal + HistoryCommentRevision + { content, + subject, + revisionId = commentRevisionHash, + authorSignature, + comment = HistoryComment {author, authorThumbprint, causal = causalHashId, commentId = commentHash} + } = do + commentHashId <- saveHistoryCommentHash commentHash + commentRevisionHashId <- saveHistoryCommentRevisionHash commentRevisionHash + thumbprintId <- ensurePersonalKeyThumbprintId authorThumbprint + mayExistingCommentId <- + queryMaybeCol @HistoryCommentId + [sql| SELECT id FROM history_comments WHERE causal_hash_id = :causalHashId |] - commentId <- case mayExistingCommentId of - Nothing -> - queryOneCol @HistoryCommentId - [sql| - INSERT INTO history_comments (author, causal_hash_id, created_at) - VALUES (:author, :causalHashId, strftime('%s', 'now', 'subsec')) + now <- Sqlite.unsafeIO $ Time.getCurrentTime + createdAtMs <- pure $ utcTimeToMillis now + commentId <- case mayExistingCommentId of + Nothing -> + queryOneCol @HistoryCommentId + [sql| + INSERT INTO history_comments (comment_hash_id, author_thumbprint_id, author, causal_hash_id, created_at_ms) + VALUES (:commentHashId, :thumbprintId, :author, :causalHashId, :createdAtMs) RETURNING id |] - Just cid -> pure cid - execute - [sql| - INSERT INTO history_comment_revisions (comment_id, subject, contents, created_at) - VALUES (:commentId, :subject, :content, strftime('%s', 'now', 'subsec')) + Just cid -> pure cid + execute + [sql| + INSERT INTO history_comment_revisions (revision_hash_id, comment_id, subject, contents, author_signature, created_at_ms) + VALUES (:commentRevisionHashId, :commentId, :subject, :content, :authorSignature, :createdAtMs) |] getAuthorName :: Transaction (Maybe AuthorName) @@ -4192,3 +4276,24 @@ getConfigValue key = FROM config WHERE key = :key |] + +-- | Save or return the id for a given key thumbprint +ensurePersonalKeyThumbprintId :: KeyThumbprint -> Transaction KeyThumbprintId +ensurePersonalKeyThumbprintId thumbprint = do + let thumbprintText = thumbprintToText thumbprint + mayExisting <- + queryMaybeCol + [sql| + SELECT id + FROM key_thumbprints + WHERE thumbprint = :thumbprintText + |] + case mayExisting of + Just thumbprintId -> pure thumbprintId + Nothing -> + queryOneCol + [sql| + INSERT INTO key_thumbprints (thumbprint) + VALUES (:thumbprintText) + RETURNING id + |] diff --git a/codebase2/codebase-sqlite/sql/021-hash-history-comments.sql b/codebase2/codebase-sqlite/sql/021-hash-history-comments.sql new file mode 100644 index 0000000000..08aaffc852 --- /dev/null +++ b/codebase2/codebase-sqlite/sql/021-hash-history-comments.sql @@ -0,0 +1,28 @@ +-- Table for storing personal key thumbprints, +-- which we may later associate with users. +CREATE TABLE IF NOT EXISTS key_thumbprints ( + id INTEGER PRIMARY KEY, + thumbprint TEXT UNIQUE NOT NULL +); + +ALTER TABLE history_comments + -- The hash used for comment identity. + -- It's the hash of (causal_hash <> author <> created_at_ms) + ADD COLUMN comment_hash_id INTEGER NULL REFERENCES hash(id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_history_comments_comment_hash_id + ON history_comments(comment_hash_id); + +ALTER TABLE history_comments + ADD COLUMN author_thumbprint_id INTEGER NULL REFERENCES key_thumbprints(id); + +ALTER TABLE history_comment_revisions + -- The hash used for this revision's identity. + -- It's the hash of (comment_hash <> subject <> contents <> hidden <> created_at_ms) + ADD COLUMN revision_hash_id INTEGER NULL REFERENCES hash(id); + +ALTER TABLE history_comment_revisions + ADD COLUMN author_signature BLOB NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_history_comment_revisions_revision_hash_id + ON history_comment_revisions(revision_hash_id); diff --git a/codebase2/codebase-sqlite/sql/022-hash-history-comments-cleanup.sql b/codebase2/codebase-sqlite/sql/022-hash-history-comments-cleanup.sql new file mode 100644 index 0000000000..96e111ea66 --- /dev/null +++ b/codebase2/codebase-sqlite/sql/022-hash-history-comments-cleanup.sql @@ -0,0 +1,60 @@ +-- Assert hash columns are non-nullable after we've filled in all the values. +-- SQLite annoying does not support doing this in place, so we need to make a new table. + +CREATE TABLE history_comments_new ( + id INTEGER PRIMARY KEY, + causal_hash_id INTEGER NOT NULL REFERENCES hash(id), + author TEXT NOT NULL, + + -- Remember that SQLITE doesn't have any actual 'time' type, + -- This column contains the number of milliseconds since epoch + -- as an integer. + created_at_ms INTEGER NOT NULL, + + comment_hash_id INTEGER UNIQUE NOT NULL REFERENCES hash(id), + author_thumbprint_id INTEGER NOT NULL REFERENCES key_thumbprints(id) +); + +-- Copy data from old tables to new tables. +-- We convert the created_at to created_at_ms by multiplying by 1000 and casting to INTEGER. +INSERT INTO history_comments_new (id, causal_hash_id, author, created_at_ms, comment_hash_id, author_thumbprint_id) + SELECT id, causal_hash_id, author, CAST((created_at * 1000) AS INTEGER), comment_hash_id, author_thumbprint_id + FROM history_comments; + +-- Now do the revisions +CREATE TABLE history_comment_revisions_new ( + id INTEGER PRIMARY KEY, + comment_id INTEGER NOT NULL REFERENCES history_comments_new(id), + subject TEXT NOT NULL, + contents TEXT NOT NULL, + + -- Remember that SQLITE doesn't have any actual 'time' type, + -- This column contains the number of milliseconds since epoch + -- as an integer. + created_at_ms INTEGER NOT NULL, + + -- - In a distributed system you really can’t ever truly delete comments, + -- but you can ask to hide them. + hidden BOOL NOT NULL DEFAULT FALSE, + + revision_hash_id INTEGER UNIQUE NOT NULL REFERENCES hash(id), + + -- The signature of the author on the revision hash. + author_signature BLOB NOT NULL +); + +-- We convert the created_at to created_at_ms by multiplying by 1000 and casting to INTEGER. +INSERT INTO history_comment_revisions_new (id, comment_id, subject, contents, created_at_ms, hidden, author_signature, revision_hash_id) + SELECT id, comment_id, subject, contents, CAST((created_at * 1000) AS INTEGER), hidden, author_signature, revision_hash_id + FROM history_comment_revisions; + +-- Drop old tables +DROP TABLE history_comment_revisions; +DROP TABLE history_comments; + +-- Rename new tables to original table names +ALTER TABLE history_comments_new RENAME TO history_comments; +ALTER TABLE history_comment_revisions_new RENAME TO history_comment_revisions; + +CREATE INDEX history_comments_by_causal_hash_id ON history_comments(causal_hash_id, created_at_ms DESC); +CREATE INDEX history_comment_revisions_by_comment_id_and_created_at_ms ON history_comment_revisions(comment_id, created_at_ms DESC); diff --git a/codebase2/codebase-sqlite/unison-codebase-sqlite.cabal b/codebase2/codebase-sqlite/unison-codebase-sqlite.cabal index f6dc340e7c..69db004787 100644 --- a/codebase2/codebase-sqlite/unison-codebase-sqlite.cabal +++ b/codebase2/codebase-sqlite/unison-codebase-sqlite.cabal @@ -30,6 +30,8 @@ extra-source-files: sql/018-add-derived-dependents-by-dependency-index.sql sql/019-add-upgrade-branch-table.sql sql/020-add-history-comments.sql + sql/021-hash-history-comments.sql + sql/022-hash-history-comments-cleanup.sql sql/create.sql source-repository head @@ -50,7 +52,6 @@ library U.Codebase.Sqlite.Decode U.Codebase.Sqlite.Entity U.Codebase.Sqlite.HashHandle - U.Codebase.Sqlite.HistoryComment U.Codebase.Sqlite.LocalIds U.Codebase.Sqlite.LocalizeObject U.Codebase.Sqlite.ObjectType diff --git a/codebase2/core/U/Codebase/HashTags.hs b/codebase2/core/U/Codebase/HashTags.hs index 5470f3009f..79bad7a8c0 100644 --- a/codebase2/core/U/Codebase/HashTags.hs +++ b/codebase2/core/U/Codebase/HashTags.hs @@ -18,6 +18,12 @@ newtype CausalHash = CausalHash {unCausalHash :: Hash} newtype PatchHash = PatchHash {unPatchHash :: Hash} deriving stock (Eq, Ord) +newtype HistoryCommentHash = HistoryCommentHash {unHistoryCommentHash :: Hash} + deriving stock (Eq, Ord) + +newtype HistoryCommentRevisionHash = HistoryCommentRevisionHash {unHistoryCommentRevisionHash :: Hash} + deriving stock (Eq, Ord) + instance Show ComponentHash where show h = "ComponentHash (" ++ show (unComponentHash h) ++ ")" @@ -30,6 +36,12 @@ instance Show CausalHash where instance Show PatchHash where show h = "PatchHash (" ++ show (unPatchHash h) ++ ")" +instance Show HistoryCommentHash where + show h = "HistoryCommentHash (" ++ show (unHistoryCommentHash h) ++ ")" + +instance Show HistoryCommentRevisionHash where + show h = "HistoryCommentRevisionHash (" ++ show (unHistoryCommentRevisionHash h) ++ ")" + instance From ComponentHash Text where from = from @Hash @Text . unComponentHash @@ -42,6 +54,12 @@ instance From CausalHash Text where instance From PatchHash Text where from = from @Hash @Text . unPatchHash +instance From HistoryCommentHash Text where + from = from @Hash @Text . unHistoryCommentHash + +instance From HistoryCommentRevisionHash Text where + from = from @Hash @Text . unHistoryCommentRevisionHash + instance From ComponentHash Hash instance From BranchHash Hash @@ -50,6 +68,10 @@ instance From CausalHash Hash instance From PatchHash Hash +instance From HistoryCommentHash Hash + +instance From HistoryCommentRevisionHash Hash + instance From Hash ComponentHash instance From Hash BranchHash @@ -58,6 +80,10 @@ instance From Hash CausalHash instance From Hash PatchHash +instance From Hash HistoryCommentHash + +instance From Hash HistoryCommentRevisionHash + instance From ComponentHash Hash32 where from = from @Hash @Hash32 . unComponentHash @@ -81,3 +107,9 @@ instance From Hash32 CausalHash where instance From Hash32 PatchHash where from = PatchHash . from @Hash32 @Hash + +instance From HistoryCommentHash Hash32 where + from = from @Hash @Hash32 . unHistoryCommentHash + +instance From HistoryCommentRevisionHash Hash32 where + from = from @Hash @Hash32 . unHistoryCommentRevisionHash diff --git a/hie.yaml b/hie.yaml index 66cbd83fcd..cda2695c12 100644 --- a/hie.yaml +++ b/hie.yaml @@ -42,6 +42,10 @@ cradle: - path: "lib/unison-hashing/src" component: "unison-hashing:lib" + - path: "lib/unison-credentials/src" + component: "unison-credentials:lib" + + - path: "lib/unison-prelude/src" component: "unison-prelude:lib" @@ -98,7 +102,7 @@ cradle: - path: "parser-typechecker/tests" component: "unison-parser-typechecker:test:parser-typechecker-tests" - + - path: "unison-runtime/src" component: "unison-runtime:lib" diff --git a/lib/unison-credentials/package.yaml b/lib/unison-credentials/package.yaml new file mode 100644 index 0000000000..6eac81cfcf --- /dev/null +++ b/lib/unison-credentials/package.yaml @@ -0,0 +1,62 @@ +name: unison-credentials +github: unisonweb/unison +copyright: Copyright (C) 2013-2025 Unison Computing, PBC and contributors + +ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-missing-pattern-synonym-signatures + +dependencies: + - base + - aeson + - bytestring + - containers + - crypton + - filepath + - jose + - text + - base64-bytestring + - lens + - lock-file + - memory + - mtl + - network-uri + - time + - transformers + - unison-core1 + - unison-prelude + - unliftio + +library: + source-dirs: src + when: + - condition: false + other-modules: Paths_unison_credentials + +default-extensions: + - ApplicativeDo + - BangPatterns + - BlockArguments + - DeriveAnyClass + - DeriveFunctor + - DeriveGeneric + - DeriveTraversable + - DerivingStrategies + - DerivingVia + - DoAndIfThenElse + - DuplicateRecordFields + - FlexibleContexts + - FlexibleInstances + - GeneralizedNewtypeDeriving + - ImportQualifiedPost + - LambdaCase + - MultiParamTypeClasses + - NamedFieldPuns + - OverloadedRecordDot + - OverloadedStrings + - PatternSynonyms + - RankNTypes + - ScopedTypeVariables + - StandaloneDeriving + - TupleSections + - TypeApplications + - TypeFamilies + - ViewPatterns diff --git a/unison-cli/src/Unison/Auth/CredentialFile.hs b/lib/unison-credentials/src/Unison/Auth/CredentialFile.hs similarity index 64% rename from unison-cli/src/Unison/Auth/CredentialFile.hs rename to lib/unison-credentials/src/Unison/Auth/CredentialFile.hs index 1e746bd701..8db3323fb4 100644 --- a/unison-cli/src/Unison/Auth/CredentialFile.hs +++ b/lib/unison-credentials/src/Unison/Auth/CredentialFile.hs @@ -6,7 +6,6 @@ import Data.Aeson qualified as Aeson import System.FilePath (takeDirectory, ()) import System.IO.LockFile import Unison.Auth.Types -import Unison.Debug qualified as Debug import Unison.Prelude import UnliftIO.Directory @@ -26,26 +25,26 @@ getCredentialJSONFilePath = do -- | Atomically update the credential storage file. -- Creates an empty file automatically if one doesn't exist. -atomicallyModifyCredentialsFile :: (MonadIO m) => (Credentials -> Credentials) -> m Credentials -atomicallyModifyCredentialsFile f = liftIO $ do - credentialJSONPath <- getCredentialJSONFilePath - doesFileExist credentialJSONPath >>= \case +atomicallyModifyCredentialsFile :: (MonadUnliftIO m) => (Credentials -> m (Credentials, r)) -> m r +atomicallyModifyCredentialsFile f = do + credentialJSONPath <- liftIO $ getCredentialJSONFilePath + liftIO (doesFileExist credentialJSONPath) >>= \case True -> pure () - False -> do + False -> liftIO $ do createDirectoryIfMissing True $ takeDirectory credentialJSONPath Aeson.encodeFile credentialJSONPath emptyCredentials - withLockFile lockfileConfig (withLockExt credentialJSONPath) $ do + toIO <- askRunInIO + liftIO $ withLockFile lockfileConfig (withLockExt credentialJSONPath) $ toIO $ do credentials <- - Aeson.eitherDecodeFileStrict credentialJSONPath >>= \case + liftIO (Aeson.eitherDecodeFileStrict credentialJSONPath) >>= \case -- If something goes wrong, just wipe the credentials file so we're in a clean slate. -- In the worst case the user will simply need to log in again. - Left err -> do - Debug.debugM Debug.Auth "Error decoding credentials file" err - Aeson.encodeFile credentialJSONPath emptyCredentials + Left _err -> do + liftIO $ Aeson.encodeFile credentialJSONPath emptyCredentials pure emptyCredentials Right creds -> pure creds - let newCredentials = f credentials + (newCredentials, r) <- f credentials when (newCredentials /= credentials) $ do - Aeson.encodeFile credentialJSONPath $ newCredentials - pure newCredentials + liftIO $ Aeson.encodeFile credentialJSONPath newCredentials + pure r diff --git a/lib/unison-credentials/src/Unison/Auth/CredentialManager.hs b/lib/unison-credentials/src/Unison/Auth/CredentialManager.hs new file mode 100644 index 0000000000..c3c5b434e1 --- /dev/null +++ b/lib/unison-credentials/src/Unison/Auth/CredentialManager.hs @@ -0,0 +1,84 @@ +{-# LANGUAGE DeriveAnyClass #-} + +module Unison.Auth.CredentialManager + ( saveCredentials, + CredentialManager, + globalCredentialManager, + getCodeserverCredentials, + getOrCreatePersonalKey, + isExpired, + ) +where + +import Control.Monad.Trans.Except +import Data.Map qualified as Map +import Data.Time.Clock (addUTCTime, diffUTCTime, getCurrentTime) +import System.IO.Unsafe (unsafePerformIO) +import Unison.Auth.CredentialFile qualified as CF +import Unison.Auth.PersonalKey (PersonalPrivateKey, generatePersonalKey) +import Unison.Auth.Types hiding (getCodeserverCredentials) +import Unison.Auth.Types qualified as Auth +import Unison.Prelude +import Unison.Share.Types (CodeserverId) +import UnliftIO qualified + +-- | A 'CredentialManager' knows how to load, save, and cache credentials. +-- It's thread-safe and safe for use across multiple UCM clients. +-- Note: Currently the in-memory cache is _not_ updated if a different UCM updates +-- the credentials file, however this shouldn't pose any problems, since auth will still +-- be refreshed if we encounter any auth failures on requests. +newtype CredentialManager = CredentialManager (UnliftIO.MVar (Maybe Credentials {- Credentials may or may not be initialized -})) + +-- | A global CredentialManager instance/singleton. +globalCredentialManager :: CredentialManager +globalCredentialManager = unsafePerformIO do + CredentialManager <$> UnliftIO.newMVar Nothing +{-# NOINLINE globalCredentialManager #-} + +-- | Fetches the user's personal key from the active profile, if it exists. +-- Otherwise it creates a new personal key, saves it to the active profile, and returns it. +getOrCreatePersonalKey :: (MonadUnliftIO m) => CredentialManager -> m PersonalPrivateKey +getOrCreatePersonalKey credMan = do + modifyCredentials credMan \creds@(Credentials {activeProfile, personalKeys}) -> do + case Map.lookup activeProfile personalKeys of + Just pk -> pure (creds, pk) + Nothing -> do + pk <- generatePersonalKey + pure (creds {personalKeys = Map.insert activeProfile pk personalKeys}, pk) + +-- | Saves credentials to the active profile. +saveCredentials :: (UnliftIO.MonadUnliftIO m) => CredentialManager -> CodeserverId -> CodeserverCredentials -> m () +saveCredentials credManager aud creds = do + void . modifyCredentials credManager $ \cf -> pure (setCodeserverCredentials aud creds cf, ()) + +-- | Atomically update the credential storage file, and update the in-memory cache. +modifyCredentials :: (UnliftIO.MonadUnliftIO m) => CredentialManager -> (Credentials -> m (Credentials, r)) -> m r +modifyCredentials (CredentialManager credsVar) f = do + UnliftIO.modifyMVar credsVar $ \_ -> do + (creds, r) <- CF.atomicallyModifyCredentialsFile (f >=> \(creds', r') -> pure (creds', (creds', r'))) + pure (Just creds, r) + +readCredentials :: (UnliftIO.MonadUnliftIO m) => CredentialManager -> m Credentials +readCredentials (CredentialManager credsVar) = do + UnliftIO.modifyMVar credsVar $ \mayCreds -> case mayCreds of + Just creds -> pure (mayCreds, creds) + Nothing -> do + creds <- CF.atomicallyModifyCredentialsFile \c -> pure (c, c) + pure (Just creds, creds) + +getCodeserverCredentials :: (MonadIO m) => CredentialManager -> CodeserverId -> m (Either CredentialFailure CodeserverCredentials) +getCodeserverCredentials credMan aud = runExceptT do + creds <- liftIO $ readCredentials credMan + codeserverCreds <- except (Auth.getCodeserverCredentials aud creds) + lift (isExpired codeserverCreds) >>= \case + True -> throwE (ReauthRequired aud) + False -> pure codeserverCreds + +-- | Checks whether CodeserverCredentials are expired. +isExpired :: (MonadIO m) => CodeserverCredentials -> m Bool +isExpired CodeserverCredentials {fetchTime, tokens = Tokens {expiresIn}} = liftIO do + now <- getCurrentTime + let expTime = addUTCTime expiresIn fetchTime + let remainingTime = diffUTCTime expTime now + let threshold = expiresIn * 0.1 + pure (threshold >= remainingTime) diff --git a/lib/unison-credentials/src/Unison/Auth/PersonalKey.hs b/lib/unison-credentials/src/Unison/Auth/PersonalKey.hs new file mode 100644 index 0000000000..e4ba11b4fc --- /dev/null +++ b/lib/unison-credentials/src/Unison/Auth/PersonalKey.hs @@ -0,0 +1,111 @@ +-- Module for working with "PersonalKeys" in Unison Auth. +-- +-- A Personal Key is just an Ed25519 EdDSA key pair. +-- We use the private key to make assertions on behalf of the user, +-- such as signing comments. +-- +-- Then we can register the public key with our share user account. +-- to link the key to the user. + +module Unison.Auth.PersonalKey + ( PersonalPrivateKey, + encodePrivateKey, + PersonalPublicKey, + publicKey, + generatePersonalKey, + personalKeyThumbprint, + signWithPersonalKey, + verifyWithPersonalKey, + PersonalKeySignature (..), + ) +where + +import Control.Monad.Error.Class +import Control.Monad.Trans.Except +import Crypto.JOSE qualified as JOSE +import Crypto.JOSE.JWA.JWK qualified as JWA +import Crypto.JOSE.JWK (JWK, KeyMaterialGenParam (OKPGenParam), OKPCrv (Ed25519), genJWK) +import Crypto.JOSE.JWK qualified as JWK +import Crypto.JOSE.JWS qualified as JWS +import Crypto.Random +import Data.Aeson (ToJSON) +import Data.Aeson qualified as Aeson +import Data.Aeson.Types (Value) +import Data.ByteArray qualified as ByteArray +import Data.ByteString qualified as BS +import Data.ByteString.Base64.URL qualified as Base64URL +import Data.Text.Encoding qualified as Text +import Unison.KeyThumbprint (KeyThumbprint (..)) +import Unison.Prelude + +-- | A JWK representing a personal key +newtype PersonalPrivateKey = PersonalPrivateKey {_personalPrivateKeyJWK :: JWK} + deriving stock (Eq) + deriving newtype (Aeson.FromJSON) + +personalKeyThumbprint :: PersonalPrivateKey -> KeyThumbprint +personalKeyThumbprint (PersonalPrivateKey jwk) = jwkThumbprint jwk + +jwkThumbprint :: JWK.JWK -> KeyThumbprint +jwkThumbprint jwk = + jwk ^. JWK.thumbprint @JWK.SHA256 + & ByteArray.convert + & Base64URL.encodeUnpadded + & Text.decodeUtf8 + & KeyThumbprint + +-- | Encode the private JWK. +-- +-- I left off a ToJSON instance because I want to be explicit about when +-- we're encoding the private key. +encodePrivateKey :: PersonalPrivateKey -> Value +encodePrivateKey (PersonalPrivateKey jwk) = Aeson.toJSON jwk + +publicKey :: PersonalPrivateKey -> PersonalPublicKey +publicKey (PersonalPrivateKey jwk) = case (jwk ^. JWK.asPublicKey) of + Just public -> PersonalPublicKey public + Nothing -> error "publicKey: Failed to extract public key from private key. This should never happen." + +newtype PersonalPublicKey = PersonalPublicKey {_personalPublicKeyJWK :: JWK} + deriving newtype (ToJSON) + +-- Generate a single Ed25519 JWK +generatePersonalKey :: (MonadIO m) => m PersonalPrivateKey +generatePersonalKey = liftIO $ do + genJWK @IO (OKPGenParam Ed25519) + <&> JWK.jwkUse .~ Just JWK.Sig + <&> JWK.jwkAlg .~ Just (JWK.JWSAlg JWS.EdDSA) + <&> (\j -> j & JWK.jwkKid .~ Just (thumbprintToText $ jwkThumbprint j)) + <&> PersonalPrivateKey + +newtype PersonalKeySignature = PersonalKeySignature {unPersonalKeySignature :: ByteString} + deriving (Show, Eq) + +-- | For some reason `sign` and `verify` require a single monad which implements both MonadRandom and MonadError, +-- but ExceptT doesn't implement MonadRandom :| +newtype SignM a = SignM {_unSignM :: ExceptT JOSE.Error IO a} + deriving newtype (Functor, Applicative, Monad, MonadIO, MonadError JOSE.Error) + +runSignM :: (MonadIO m) => SignM a -> m (Either JOSE.Error a) +runSignM (SignM e) = liftIO $ runExceptT e + +instance MonadRandom SignM where + getRandomBytes n = SignM . liftIO $ getRandomBytes n + +-- | Sign arbitrary bytes using a personal private key +-- +-- >>> key <- generatePersonalKey +-- >>> let msg = "Hello, world!" +-- >>> signature <- fromRight (error "failed to sign") <$> signWithPersonalKey key msg +-- >>> verifyWithPersonalKey (publicKey key) msg signature +-- True +signWithPersonalKey :: (MonadIO m) => PersonalPrivateKey -> BS.ByteString -> m (Either JOSE.Error PersonalKeySignature) +signWithPersonalKey (PersonalPrivateKey jwk) bytes = runSignM $ do + PersonalKeySignature <$> (JWA.sign @SignM JWS.EdDSA (jwk ^. JWS.jwkMaterial) bytes) + +-- | Verify a signature made with a personal private key +verifyWithPersonalKey :: (MonadIO m) => PersonalPublicKey -> BS.ByteString -> PersonalKeySignature -> m Bool +verifyWithPersonalKey (PersonalPublicKey jwk) bytes (PersonalKeySignature signature) = + (JWA.verify @JOSE.Error @SignM JWS.EdDSA (jwk ^. JWS.jwkMaterial) bytes signature) + & runSignM + <&> fromRight False diff --git a/unison-cli/src/Unison/Auth/Types.hs b/lib/unison-credentials/src/Unison/Auth/Types.hs similarity index 92% rename from unison-cli/src/Unison/Auth/Types.hs rename to lib/unison-credentials/src/Unison/Auth/Types.hs index e557577ce4..09df35258e 100644 --- a/unison-cli/src/Unison/Auth/Types.hs +++ b/lib/unison-credentials/src/Unison/Auth/Types.hs @@ -31,6 +31,7 @@ import Data.Text qualified as Text import Data.Time (NominalDiffTime, UTCTime) import Network.URI import Network.URI qualified as URI +import Unison.Auth.PersonalKey (PersonalPrivateKey, encodePrivateKey) import Unison.Prelude import Unison.Share.Types @@ -133,20 +134,24 @@ type ProfileName = Text data Credentials = Credentials { credentials :: Map ProfileName (Map CodeserverId CodeserverCredentials), + personalKeys :: Map ProfileName PersonalPrivateKey, activeProfile :: ProfileName } deriving (Eq) instance Aeson.ToJSON Credentials where - toJSON (Credentials credMap activeProfile) = + toJSON (Credentials {credentials, personalKeys, activeProfile}) = Aeson.object - [ "credentials" .= credMap, + [ "credentials" .= credentials, + "personal_keys" .= (encodePrivateKey <$> personalKeys), "active_profile" .= activeProfile ] instance Aeson.FromJSON Credentials where parseJSON = Aeson.withObject "Credentials" $ \obj -> do credentials <- obj .: "credentials" + -- If there are no personal keys, default to an empty map for back-compat. + personalKeys <- fromMaybe Map.empty <$> obj .:? "personal_keys" activeProfile <- obj .: "active_profile" pure Credentials {..} @@ -207,7 +212,7 @@ instance FromJSON CodeserverCredentials where pure $ CodeserverCredentials {..} emptyCredentials :: Credentials -emptyCredentials = Credentials mempty defaultProfileName +emptyCredentials = Credentials mempty mempty defaultProfileName codeserverCredentials :: URI -> Tokens -> UTCTime -> UserInfo -> CodeserverCredentials codeserverCredentials discoveryURI tokens fetchTime userInfo = CodeserverCredentials {discoveryURI, fetchTime, tokens, userInfo} diff --git a/lib/unison-credentials/unison-credentials.cabal b/lib/unison-credentials/unison-credentials.cabal new file mode 100644 index 0000000000..a209064b74 --- /dev/null +++ b/lib/unison-credentials/unison-credentials.cabal @@ -0,0 +1,76 @@ +cabal-version: 1.12 + +-- This file has been generated from package.yaml by hpack version 0.38.1. +-- +-- see: https://github.com/sol/hpack + +name: unison-credentials +version: 0.0.0 +homepage: https://github.com/unisonweb/unison#readme +bug-reports: https://github.com/unisonweb/unison/issues +copyright: Copyright (C) 2013-2025 Unison Computing, PBC and contributors +build-type: Simple + +source-repository head + type: git + location: https://github.com/unisonweb/unison + +library + exposed-modules: + Unison.Auth.CredentialFile + Unison.Auth.CredentialManager + Unison.Auth.PersonalKey + Unison.Auth.Types + hs-source-dirs: + src + default-extensions: + ApplicativeDo + BangPatterns + BlockArguments + DeriveAnyClass + DeriveFunctor + DeriveGeneric + DeriveTraversable + DerivingStrategies + DerivingVia + DoAndIfThenElse + DuplicateRecordFields + FlexibleContexts + FlexibleInstances + GeneralizedNewtypeDeriving + ImportQualifiedPost + LambdaCase + MultiParamTypeClasses + NamedFieldPuns + OverloadedRecordDot + OverloadedStrings + PatternSynonyms + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + ViewPatterns + ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-missing-pattern-synonym-signatures + build-depends: + aeson + , base + , base64-bytestring + , bytestring + , containers + , crypton + , filepath + , jose + , lens + , lock-file + , memory + , mtl + , network-uri + , text + , time + , transformers + , unison-core1 + , unison-prelude + , unliftio + default-language: Haskell2010 diff --git a/lib/unison-sqlite/src/Unison/Sqlite/Utils.hs b/lib/unison-sqlite/src/Unison/Sqlite/Utils.hs index 84744b56b0..21f237041b 100644 --- a/lib/unison-sqlite/src/Unison/Sqlite/Utils.hs +++ b/lib/unison-sqlite/src/Unison/Sqlite/Utils.hs @@ -1,4 +1,7 @@ -module Unison.Sqlite.Utils (likeEscape) where +module Unison.Sqlite.Utils + ( likeEscape, + ) +where import Data.Text (Text) import Data.Text qualified as Text diff --git a/lib/unison-sqlite/unison-sqlite.cabal b/lib/unison-sqlite/unison-sqlite.cabal index 9bd012a20b..43388a8484 100644 --- a/lib/unison-sqlite/unison-sqlite.cabal +++ b/lib/unison-sqlite/unison-sqlite.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.36.0. +-- This file has been generated from package.yaml by hpack version 0.38.1. -- -- see: https://github.com/sol/hpack diff --git a/parser-typechecker/package.yaml b/parser-typechecker/package.yaml index 29ff1f65d0..e4d973f45f 100644 --- a/parser-typechecker/package.yaml +++ b/parser-typechecker/package.yaml @@ -20,6 +20,7 @@ library: - bytestring - concurrent-output - containers >= 0.6.3 + - cryptonite - errors - extra - filelock @@ -30,16 +31,15 @@ library: - hashtables - lens - megaparsec + - memory - mmorph - mtl - murmur-hash - mutable-containers - - network-uri - nonempty-containers - pretty-simple - regex-tdfa - semialign - - servant-client - stm - text - these @@ -52,6 +52,7 @@ library: - unison-codebase-sync - unison-core - unison-core1 + - unison-credentials - unison-hash - unison-hashing-v2 - unison-prelude diff --git a/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Migrations.hs b/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Migrations.hs index c39b90fee5..4927c52cea 100644 --- a/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Migrations.hs +++ b/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Migrations.hs @@ -14,12 +14,15 @@ import Text.Printf (printf) import U.Codebase.Reference qualified as C.Reference import U.Codebase.Sqlite.DbId (HashVersion (..), SchemaVersion (..)) import U.Codebase.Sqlite.Queries qualified as Q +import Unison.Auth.CredentialManager qualified as CredMan +import Unison.Auth.PersonalKey (PersonalPrivateKey) import Unison.Codebase (CodebasePath) import Unison.Codebase.Init (BackupStrategy (..), VacuumStrategy (..)) import Unison.Codebase.Init.OpenCodebaseError (OpenCodebaseError (OpenCodebaseUnknownSchemaVersion)) import Unison.Codebase.Init.OpenCodebaseError qualified as Codebase import Unison.Codebase.IntegrityCheck (IntegrityResult (..), integrityCheckAllBranches, integrityCheckAllCausals, prettyPrintIntegrityErrors) import Unison.Codebase.SqliteCodebase.Migrations.Helpers (abortMigration) +import Unison.Codebase.SqliteCodebase.Migrations.MigrateHistoryComments (hashHistoryCommentsMigration) import Unison.Codebase.SqliteCodebase.Migrations.MigrateSchema11To12 (migrateSchema11To12) import Unison.Codebase.SqliteCodebase.Migrations.MigrateSchema16To17 (migrateSchema16To17) import Unison.Codebase.SqliteCodebase.Migrations.MigrateSchema1To2 (migrateSchema1To2) @@ -43,6 +46,7 @@ import UnliftIO qualified -- | Mapping from schema version to the migration required to get there. -- E.g. The migration at index 2 must be run on a codebase at version 1. migrations :: + PersonalPrivateKey -> (MVar Region.ConsoleRegion) -> -- | A 'getDeclType'-like lookup, possibly backed by a cache. (C.Reference.Reference -> Sqlite.Transaction CT.ConstructorType) -> @@ -50,7 +54,7 @@ migrations :: TVar (Map Hash Ops2.DeclBufferEntry) -> CodebasePath -> Map SchemaVersion (Sqlite.Connection -> IO ()) -migrations regionVar getDeclType termBuffer declBuffer rootCodebasePath = +migrations personalKey regionVar getDeclType termBuffer declBuffer rootCodebasePath = Map.fromList [ (2, runT $ migrateSchema1To2 getDeclType termBuffer declBuffer), -- The 1 to 2 migration kept around hash objects of hash version 1, unfortunately this @@ -90,7 +94,10 @@ migrations regionVar getDeclType termBuffer declBuffer rootCodebasePath = sqlMigration 20 Q.addUpdateBranchTable, sqlMigration 21 Q.addDerivedDependentsByDependencyIndex, sqlMigration 22 Q.addUpgradeBranchTable, - sqlMigration 23 Q.addHistoryComments + sqlMigration 23 Q.addHistoryComments, + sqlMigration 24 Q.addHistoryCommentHashing, + (25, runT $ hashHistoryCommentsMigration personalKey), + sqlMigration 26 Q.historyCommentHashingCleanup ] where runT :: Sqlite.Transaction () -> Sqlite.Connection -> IO () @@ -125,6 +132,8 @@ checkCodebaseIsUpToDate = do | schemaVersion < Q.currentSchemaVersion -> CodebaseRequiresMigration schemaVersion Q.currentSchemaVersion | otherwise -> CodebaseUnknownSchemaVersion schemaVersion +type PersonalKeyThumbprint = Text + -- | Migrates a codebase up to the most recent version known to ucm. -- This is a No-op if it's up to date -- Returns an error if the schema version is newer than this ucm knows about. @@ -152,7 +161,9 @@ ensureCodebaseIsUpToDate localOrRemote root getDeclType termBuffer declBuffer sh Region.displayConsoleRegions do (`UnliftIO.finally` finalizeRegion) do - let migs = migrations regionVar getDeclType termBuffer declBuffer root + let credMan = CredMan.globalCredentialManager + personalKey <- CredMan.getOrCreatePersonalKey credMan + let migs = migrations personalKey regionVar getDeclType termBuffer declBuffer root -- The highest schema that this ucm knows how to migrate to. let highestKnownSchemaVersion = fst . head $ Map.toDescList migs currentSchemaVersion <- Sqlite.runTransaction conn Q.schemaVersion diff --git a/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Migrations/MigrateHistoryComments.hs b/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Migrations/MigrateHistoryComments.hs new file mode 100644 index 0000000000..391ac2acd2 --- /dev/null +++ b/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Migrations/MigrateHistoryComments.hs @@ -0,0 +1,108 @@ +{-# LANGUAGE NumericUnderscores #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TemplateHaskell #-} + +module Unison.Codebase.SqliteCodebase.Migrations.MigrateHistoryComments (hashHistoryCommentsMigration) where + +import Data.Time (UTCTime) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) +import U.Codebase.HashTags +import U.Codebase.Sqlite.DbId (HistoryCommentId (..), HistoryCommentRevisionId (HistoryCommentRevisionId)) +import U.Codebase.Sqlite.Orphans (AsSqlite (..)) +import U.Codebase.Sqlite.Queries qualified as Q +import Unison.Auth.PersonalKey (PersonalPrivateKey) +import Unison.Auth.PersonalKey qualified as PersonalKey +import Unison.Debug qualified as Debug +import Unison.Hash (Hash) +import Unison.Hash qualified as Hash +import Unison.HistoryComment (HistoryComment (..), HistoryCommentRevision (..)) +import Unison.HistoryComments.Hashing (hashHistoryComment, hashHistoryCommentRevision) +import Unison.Prelude +import Unison.Sqlite qualified as Sqlite + +-- Convert milliseconds since epoch to UTCTime _exactly_. +-- UTCTime has picosecond precision so this is lossless. +millisToUTCTime :: Int64 -> UTCTime +millisToUTCTime ms = + toRational ms + & (/ (1_000 :: Rational)) + & fromRational + & posixSecondsToUTCTime + +-- | This migration just deletes all the old name lookups, it doesn't recreate them. +-- On share we'll rebuild only the required name lookups from scratch. +hashHistoryCommentsMigration :: PersonalPrivateKey -> Sqlite.Transaction () +hashHistoryCommentsMigration personalKey = do + Q.expectSchemaVersion 24 + hashAllHistoryComments personalKey + Q.setSchemaVersion 25 + +hashAllHistoryComments :: PersonalPrivateKey -> Sqlite.Transaction () +hashAllHistoryComments personalKey = do + let keyThumbprint = PersonalKey.personalKeyThumbprint personalKey + keyThumbprintId <- Q.ensurePersonalKeyThumbprintId keyThumbprint + historyComments <- + Sqlite.queryListRow @(HistoryCommentId, AsSqlite Hash, Text, Int64) + [Sqlite.sql| + SELECT comment.id, causal_hash.base32, comment.author, CAST(comment.created_at * 1000 AS INTEGER) + FROM history_comments comment + JOIN hash causal_hash ON comment.causal_hash_id = causal_hash.id + |] + Debug.debugM Debug.Temp "Got comments" historyComments + for_ historyComments $ \(HistoryCommentId commentId, causalHash, author, createdAtMs) -> do + let historyComment = + HistoryComment + { author, + createdAt = millisToUTCTime createdAtMs, + authorThumbprint = keyThumbprint, + causal = coerce @_ @CausalHash causalHash, + commentId = () + } + let historyCommentHash = hashHistoryComment historyComment + Debug.debugM Debug.Temp "Hashing history comment" (author, causalHash) + historyCommentHashId <- Q.saveHistoryCommentHash historyCommentHash.commentId + Sqlite.execute + [Sqlite.sql| + UPDATE history_comments + SET comment_hash_id = :historyCommentHashId, + author_thumbprint_id = :keyThumbprintId + WHERE id = :commentId + |] + historyCommentRevisions <- + Sqlite.queryListRow @(HistoryCommentRevisionId, Text, Text, Bool, Int64, AsSqlite Hash) + [Sqlite.sql| + SELECT hcr.id, hcr.subject, hcr.contents, hcr.hidden, CAST(hcr.created_at * 1000 AS INTEGER), comment_hash.base32 + FROM history_comment_revisions hcr + JOIN history_comments comment ON hcr.comment_id = comment.id + JOIN hash comment_hash ON comment.comment_hash_id = comment_hash.id + |] + Debug.debugM Debug.Temp "Got revisions" historyCommentRevisions + for_ historyCommentRevisions $ \(HistoryCommentRevisionId revisionId, subject, content, isHidden, createdAtMs, commentHash) -> do + Debug.debugM Debug.Temp "Hashing history comment revision" (subject, content) + let historyCommentRevision = + HistoryCommentRevision + { subject, + content, + isHidden, + authorSignature = mempty, + createdAt = millisToUTCTime createdAtMs, + comment = coerce @_ @HistoryCommentHash commentHash, + revisionId = () + } + let historyCommentRevisionHash = hashHistoryCommentRevision historyCommentRevision + let historyCommentRevisionHashBytes = + historyCommentRevisionHash.revisionId + & unHistoryCommentRevisionHash + & Hash.toByteString + PersonalKey.PersonalKeySignature authorSignature <- + Sqlite.unsafeIO (PersonalKey.signWithPersonalKey personalKey historyCommentRevisionHashBytes) >>= \case + Left err -> error $ "Migration failure: Failed to sign history comment revision: " ++ show err + Right sig -> pure sig + commentRevisionHashId <- Q.saveHistoryCommentRevisionHash historyCommentRevisionHash.revisionId + Sqlite.execute + [Sqlite.sql| + UPDATE history_comment_revisions + SET revision_hash_id = :commentRevisionHashId, + author_signature = :authorSignature + WHERE id = :revisionId + |] diff --git a/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Operations.hs b/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Operations.hs index cbe8e5df0b..42317ac54e 100644 --- a/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Operations.hs +++ b/parser-typechecker/src/Unison/Codebase/SqliteCodebase/Operations.hs @@ -87,6 +87,8 @@ createSchema = do Q.addDerivedDependentsByDependencyIndex Q.addUpgradeBranchTable Q.addHistoryComments + Q.addHistoryCommentHashing + Q.historyCommentHashingCleanup (_, emptyCausalHashId) <- emptyCausalHash (_, ProjectBranchRow {projectId, branchId}) <- insertProjectAndBranch scratchProjectName scratchBranchName emptyCausalHashId diff --git a/parser-typechecker/src/Unison/HistoryComments/Hashing.hs b/parser-typechecker/src/Unison/HistoryComments/Hashing.hs new file mode 100644 index 0000000000..6476718899 --- /dev/null +++ b/parser-typechecker/src/Unison/HistoryComments/Hashing.hs @@ -0,0 +1,82 @@ +{-# OPTIONS_GHC -Wno-orphans #-} + +module Unison.HistoryComments.Hashing + ( hashHistoryComment, + hashHistoryCommentRevision, + ) +where + +import Crypto.Hash qualified as CH +import Data.ByteArray qualified as BA +import Data.ByteString.Builder qualified as Builder +import Data.ByteString.Lazy.Char8 qualified as BL +import Data.Text.Encoding qualified as Text +import Data.Time (UTCTime) +import Data.Time.Clock.POSIX qualified as Time +import U.Codebase.HashTags (CausalHash, HistoryCommentHash (..), HistoryCommentRevisionHash (..)) +import Unison.Hash (Hash) +import Unison.Hash qualified as Hash +import Unison.Hashing.V2 (ContentAddressable (..)) +import Unison.HistoryComment (HistoryComment (..), HistoryCommentRevision (..)) +import Unison.KeyThumbprint (KeyThumbprint (..)) +import Unison.Prelude + +commentHashingVersion :: Int32 +commentHashingVersion = 1 + +-- Hash a base comment +instance ContentAddressable (HistoryComment UTCTime KeyThumbprint CausalHash any) where + contentHash HistoryComment {createdAt, author, causal, authorThumbprint} = + CH.hashUpdates + CH.hashInit + [ BL.toStrict . Builder.toLazyByteString $ Builder.int32BE commentHashingVersion, + Hash.toByteString (into @Hash causal), + Text.encodeUtf8 $ thumbprintToText authorThumbprint, + Text.encodeUtf8 author, + -- Encode UTCTime as a UTC 8601 seconds since epoch + createdAt + & Time.utcTimeToPOSIXSeconds + & floor + & Builder.int64BE + & Builder.toLazyByteString + & BL.toStrict + ] + & CH.hashFinalize @CH.SHA3_512 + & BA.convert + & Hash.fromByteString + +-- Hash a comment revision +instance ContentAddressable (HistoryCommentRevision any UTCTime HistoryCommentHash) where + contentHash HistoryCommentRevision {subject, content, createdAt, comment = commentHash, isHidden} = + CH.hashUpdates + CH.hashInit + [ BL.toStrict . Builder.toLazyByteString $ Builder.int32BE commentHashingVersion, + Hash.toByteString (into @Hash commentHash), + Text.encodeUtf8 subject, + Text.encodeUtf8 content, + if isHidden then "1" else "0", + -- Encode UTCTime as a UTC 8601 seconds since epoch + createdAt + & Time.utcTimeToPOSIXSeconds + & floor + & Builder.int64BE + & Builder.toLazyByteString + & BL.toStrict + ] + & CH.hashFinalize @CH.SHA3_512 + & BA.convert + & Hash.fromByteString + +hashHistoryComment :: + HistoryComment UTCTime KeyThumbprint CausalHash any -> + HistoryComment UTCTime KeyThumbprint CausalHash HistoryCommentHash +hashHistoryComment historyComment = + let commentHash = HistoryCommentHash $ contentHash historyComment + in historyComment {commentId = commentHash} + +hashHistoryCommentRevision :: + HistoryCommentRevision any UTCTime HistoryCommentHash -> + HistoryCommentRevision HistoryCommentRevisionHash UTCTime HistoryCommentHash +hashHistoryCommentRevision historyCommentRevision = + let commentRevisionHash = HistoryCommentRevisionHash $ contentHash historyCommentRevision + in historyCommentRevision {revisionId = commentRevisionHash} diff --git a/parser-typechecker/unison-parser-typechecker.cabal b/parser-typechecker/unison-parser-typechecker.cabal index 3522a51b03..1f871ee276 100644 --- a/parser-typechecker/unison-parser-typechecker.cabal +++ b/parser-typechecker/unison-parser-typechecker.cabal @@ -62,6 +62,7 @@ library Unison.Codebase.SqliteCodebase.Conversions Unison.Codebase.SqliteCodebase.Migrations Unison.Codebase.SqliteCodebase.Migrations.Helpers + Unison.Codebase.SqliteCodebase.Migrations.MigrateHistoryComments Unison.Codebase.SqliteCodebase.Migrations.MigrateSchema11To12 Unison.Codebase.SqliteCodebase.Migrations.MigrateSchema16To17 Unison.Codebase.SqliteCodebase.Migrations.MigrateSchema1To2 @@ -83,6 +84,7 @@ library Unison.DataDeclaration.Dependencies Unison.FileParsers Unison.Hashing.V2.Convert + Unison.HistoryComments.Hashing Unison.KindInference Unison.KindInference.Constraint.Context Unison.KindInference.Constraint.Pretty @@ -121,7 +123,6 @@ library Unison.PrettyPrintEnvDecl Unison.PrintError Unison.Result - Unison.Share.Types Unison.Syntax.DeclParser Unison.Syntax.DeclPrinter Unison.Syntax.FileParser @@ -203,6 +204,7 @@ library , bytestring , concurrent-output , containers >=0.6.3 + , cryptonite , errors , extra , filelock @@ -213,16 +215,15 @@ library , hashtables , lens , megaparsec + , memory , mmorph , mtl , murmur-hash , mutable-containers - , network-uri , nonempty-containers , pretty-simple , regex-tdfa , semialign - , servant-client , stm , text , these @@ -235,6 +236,7 @@ library , unison-codebase-sync , unison-core , unison-core1 + , unison-credentials , unison-hash , unison-hashing-v2 , unison-prelude diff --git a/stack.yaml b/stack.yaml index c36a96dbfb..6b8402c869 100644 --- a/stack.yaml +++ b/stack.yaml @@ -23,6 +23,7 @@ packages: - lib/orphans/unison-hash-orphans-aeson - lib/orphans/unison-hash-orphans-sqlite - lib/orphans/uuid-orphans-sqlite + - lib/unison-credentials - lib/unison-hash - lib/unison-hashing - lib/unison-prelude diff --git a/stack.yaml.lock b/stack.yaml.lock index 7f0b8a62a8..00046af8e6 100644 --- a/stack.yaml.lock +++ b/stack.yaml.lock @@ -1,7 +1,7 @@ # This file was autogenerated by Stack. # You should not edit this file by hand. # For more information, please see the documentation at: -# https://docs.haskellstack.org/en/stable/lock_files +# https://docs.haskellstack.org/en/stable/topics/lock_files packages: - completed: diff --git a/unison-cli/package.yaml b/unison-cli/package.yaml index 6257834e98..c71ac1474c 100644 --- a/unison-cli/package.yaml +++ b/unison-cli/package.yaml @@ -48,7 +48,6 @@ library: - http-types - ki - lens - - lock-file - lsp >= 2.2.0.0 - lsp-types >= 2.0.2.0 - megaparsec @@ -85,6 +84,7 @@ library: - unison-codebase-sqlite-hashing-v2 - unison-core - unison-core1 + - unison-credentials - unison-hash - unison-merge - unison-parser-typechecker diff --git a/unison-cli/src/Unison/Auth/CredentialManager.hs b/unison-cli/src/Unison/Auth/CredentialManager.hs deleted file mode 100644 index 45d789c838..0000000000 --- a/unison-cli/src/Unison/Auth/CredentialManager.hs +++ /dev/null @@ -1,60 +0,0 @@ -{-# LANGUAGE DeriveAnyClass #-} - -module Unison.Auth.CredentialManager - ( saveCredentials, - CredentialManager, - newCredentialManager, - getCredentials, - isExpired, - ) -where - -import Control.Monad.Trans.Except -import Data.Time.Clock (addUTCTime, diffUTCTime, getCurrentTime) -import Unison.Auth.CredentialFile -import Unison.Auth.Types -import Unison.Prelude -import Unison.Share.Types (CodeserverId) -import UnliftIO qualified - --- | A 'CredentialManager' knows how to load, save, and cache credentials. --- It's thread-safe and safe for use across multiple UCM clients. --- Note: Currently the in-memory cache is _not_ updated if a different UCM updates --- the credentials file, however this shouldn't pose any problems, since auth will still --- be refreshed if we encounter any auth failures on requests. -newtype CredentialManager = CredentialManager (UnliftIO.MVar Credentials) - --- | Saves credentials to the active profile. -saveCredentials :: (UnliftIO.MonadUnliftIO m) => CredentialManager -> CodeserverId -> CodeserverCredentials -> m () -saveCredentials credManager aud creds = do - void . modifyCredentials credManager $ setCodeserverCredentials aud creds - --- | Atomically update the credential storage file, and update the in-memory cache. -modifyCredentials :: (UnliftIO.MonadUnliftIO m) => CredentialManager -> (Credentials -> Credentials) -> m Credentials -modifyCredentials (CredentialManager credsVar) f = do - UnliftIO.modifyMVar credsVar $ \_ -> do - newCreds <- atomicallyModifyCredentialsFile f - pure (newCreds, newCreds) - -getCredentials :: (MonadIO m) => CredentialManager -> CodeserverId -> m (Either CredentialFailure CodeserverCredentials) -getCredentials (CredentialManager credsVar) aud = runExceptT do - creds <- lift (UnliftIO.readMVar credsVar) - codeserverCreds <- except (getCodeserverCredentials aud creds) - lift (isExpired codeserverCreds) >>= \case - True -> throwE (ReauthRequired aud) - False -> pure codeserverCreds - -newCredentialManager :: (MonadIO m) => m CredentialManager -newCredentialManager = do - credentials <- atomicallyModifyCredentialsFile id - credentialsVar <- UnliftIO.newMVar credentials - pure (CredentialManager credentialsVar) - --- | Checks whether CodeserverCredentials are expired. -isExpired :: (MonadIO m) => CodeserverCredentials -> m Bool -isExpired CodeserverCredentials {fetchTime, tokens = Tokens {expiresIn}} = liftIO do - now <- getCurrentTime - let expTime = addUTCTime expiresIn fetchTime - let remainingTime = diffUTCTime expTime now - let threshold = expiresIn * 0.1 - pure (threshold >= remainingTime) diff --git a/unison-cli/src/Unison/Auth/Tokens.hs b/unison-cli/src/Unison/Auth/Tokens.hs index be86430abf..3c5837f106 100644 --- a/unison-cli/src/Unison/Auth/Tokens.hs +++ b/unison-cli/src/Unison/Auth/Tokens.hs @@ -10,6 +10,7 @@ import Network.HTTP.Client.TLS qualified as HTTP import Network.HTTP.Types qualified as Network import System.Environment (lookupEnv) import Unison.Auth.CredentialManager +import Unison.Auth.CredentialManager qualified as CredMan import Unison.Auth.Discovery (fetchDiscoveryDoc) import Unison.Auth.Types import Unison.Auth.UserInfo (getUserInfo) @@ -37,7 +38,7 @@ newTokenProvider manager host = UnliftIO.try @_ @CredentialFailure $ do -- If the access token is provided via environment variable, we don't need to refresh it. pure accessToken Nothing -> do - creds@CodeserverCredentials {tokens, discoveryURI} <- throwEitherM $ getCredentials manager host + creds@CodeserverCredentials {tokens, discoveryURI} <- throwEitherM $ CredMan.getCodeserverCredentials manager host let Tokens {accessToken = currentAccessToken} = tokens expired <- isExpired creds if expired diff --git a/unison-cli/src/Unison/Codebase/Editor/HandleInput/AuthLogin.hs b/unison-cli/src/Unison/Codebase/Editor/HandleInput/AuthLogin.hs index 3fd6860a38..0ab2ce335e 100644 --- a/unison-cli/src/Unison/Codebase/Editor/HandleInput/AuthLogin.hs +++ b/unison-cli/src/Unison/Codebase/Editor/HandleInput/AuthLogin.hs @@ -23,7 +23,7 @@ import Network.Wai import Network.Wai qualified as Wai import Network.Wai.Handler.Warp qualified as Warp import U.Codebase.Sqlite.Queries qualified as Q -import Unison.Auth.CredentialManager (getCredentials, saveCredentials) +import Unison.Auth.CredentialManager (getCodeserverCredentials, saveCredentials) import Unison.Auth.Discovery (discoveryURIForCodeserver, fetchDiscoveryDoc) import Unison.Auth.Types ( Code, @@ -55,7 +55,7 @@ ucmOAuthClientID = "ucm" ensureAuthenticatedWithCodeserver :: CodeserverURI -> Cli UserInfo ensureAuthenticatedWithCodeserver codeserverURI = do Cli.Env {credentialManager} <- ask - getCredentials credentialManager (codeserverIdFromCodeserverURI codeserverURI) >>= \case + getCodeserverCredentials credentialManager (codeserverIdFromCodeserverURI codeserverURI) >>= \case Right (CodeserverCredentials {userInfo}) -> pure userInfo Left _ -> authLogin codeserverURI diff --git a/unison-cli/src/Unison/Codebase/Editor/HandleInput/History.hs b/unison-cli/src/Unison/Codebase/Editor/HandleInput/History.hs index 4a32e4eee8..5b104a3300 100644 --- a/unison-cli/src/Unison/Codebase/Editor/HandleInput/History.hs +++ b/unison-cli/src/Unison/Codebase/Editor/HandleInput/History.hs @@ -2,7 +2,6 @@ module Unison.Codebase.Editor.HandleInput.History (handleHistory) where import Data.Map qualified as Map import U.Codebase.HashTags -import U.Codebase.Sqlite.HistoryComment (HistoryComment (..)) import U.Codebase.Sqlite.Queries qualified as Q import Unison.Cli.Monad qualified as Cli import Unison.Cli.MonadUtils qualified as Cli @@ -14,6 +13,7 @@ import Unison.Codebase.Causal qualified as Causal import Unison.Codebase.Editor.Input import Unison.Codebase.Editor.Output import Unison.Codebase.Path (Path') +import Unison.HistoryComment (HistoryComment (..), HistoryCommentRevision (..), LatestHistoryComment) import Unison.NamesWithHistory qualified as Names import Unison.Prelude @@ -30,7 +30,7 @@ handleHistory resultsCap diffCap from = do history <- doHistory schLength 0 branch [] Cli.respondNumbered history where - doHistory :: Int -> Int -> Branch IO -> [(CausalHash, Maybe (HistoryComment () ()), Names.Diff)] -> Cli.Cli NumberedOutput + doHistory :: Int -> Int -> Branch IO -> [(CausalHash, Maybe (LatestHistoryComment () () () ()), Names.Diff)] -> Cli.Cli NumberedOutput doHistory schLength !n b acc = if maybe False (n >=) resultsCap then do @@ -49,8 +49,10 @@ handleHistory resultsCap diffCap from = do mayComment <- getComment causalHash let elem = (causalHash, mayComment, Branch.namesDiff b' b) doHistory schLength (n + 1) b' (elem : acc) - getComment :: CausalHash -> Cli.Cli (Maybe (HistoryComment () ())) + getComment :: CausalHash -> Cli.Cli (Maybe (LatestHistoryComment () () () ())) getComment ch = Cli.runTransaction $ do causalHashId <- Q.expectCausalHashIdByCausalHash ch Q.getLatestCausalComment causalHashId - <&> fmap \hc -> hc {causal = (), commentId = ()} + <&> fmap \hcr -> + let comment = hcr.comment {authorThumbprint = (), causal = (), commentId = ()} + in hcr {comment = comment, revisionId = ()} diff --git a/unison-cli/src/Unison/Codebase/Editor/HandleInput/HistoryComment.hs b/unison-cli/src/Unison/Codebase/Editor/HandleInput/HistoryComment.hs index 20b616d772..6a11021767 100644 --- a/unison-cli/src/Unison/Codebase/Editor/HandleInput/HistoryComment.hs +++ b/unison-cli/src/Unison/Codebase/Editor/HandleInput/HistoryComment.hs @@ -1,11 +1,15 @@ module Unison.Codebase.Editor.HandleInput.HistoryComment (handleHistoryComment) where +import Control.Monad.Reader import Data.Text qualified as Text import Data.Text.IO qualified as Text +import Data.Time.Clock.POSIX qualified as Time import Text.RawString.QQ (r) import U.Codebase.Config qualified as Config -import U.Codebase.Sqlite.HistoryComment (HistoryComment (..)) +import U.Codebase.HashTags import U.Codebase.Sqlite.Queries qualified as Q +import Unison.Auth.CredentialManager qualified as CredMan +import Unison.Auth.PersonalKey qualified as PK import Unison.Cli.Monad (Cli) import Unison.Cli.Monad qualified as Cli import Unison.Cli.MonadUtils qualified as Cli @@ -16,6 +20,12 @@ import Unison.Codebase.Editor.Output (Output (..)) import Unison.Codebase.Path qualified as Path import Unison.CommandLine.BranchRelativePath (BranchRelativePath (..)) import Unison.Core.Project (ProjectAndBranch (..)) +import Unison.Hash qualified as Hash +import Unison.HistoryComment (HistoryComment (..), HistoryCommentRevision (..)) +import Unison.HistoryComments.Hashing + ( hashHistoryComment, + hashHistoryCommentRevision, + ) import Unison.Prelude import UnliftIO qualified import UnliftIO.Directory (findExecutable) @@ -24,10 +34,16 @@ import UnliftIO.Process qualified as Proc handleHistoryComment :: Maybe BranchId2 -> Cli () handleHistoryComment mayThingToAnnotate = do - authorName <- - Cli.runTransaction Q.getAuthorName >>= \case - Nothing -> Cli.returnEarly $ AuthorNameRequired - Just authorName -> pure authorName + Cli.Env {credentialManager} <- ask + personalKey <- liftIO (CredMan.getOrCreatePersonalKey credentialManager) + let authorThumbprint = PK.personalKeyThumbprint personalKey + mayAuthorName <- + Cli.runTransaction do + authorName <- Q.getAuthorName + pure (authorName) + authorName <- case mayAuthorName of + Nothing -> Cli.returnEarly $ AuthorNameRequired + Just authorName -> pure authorName causalHash <- case mayThingToAnnotate of Nothing -> do Branch.headHash <$> Cli.getCurrentProjectRoot @@ -50,14 +66,45 @@ handleHistoryComment mayThingToAnnotate = do mayExistingCommentInfo <- Q.getLatestCausalComment causalHashId pure (causalHashId, mayExistingCommentInfo) let populatedMsg = fromMaybe commentInstructions $ do - HistoryComment {subject, content} <- mayHistoryComment + HistoryCommentRevision {subject, content} <- mayHistoryComment pure $ Text.unlines [subject, "", content, commentInstructions] mayNewMessage <- liftIO (editMessage (Just populatedMsg)) case mayNewMessage of - Nothing -> Cli.respond $ CommentAborted + Nothing -> Cli.returnEarly $ CommentAborted Just (subject, content) -> do - let historyComment = HistoryComment {author = Config.unAuthorName authorName, subject, content, commentId = (), causal = causalHashId} - Cli.runTransaction $ Q.commentOnCausal historyComment + createdAt <- liftIO $ Time.getCurrentTime + let historyComment = + hashHistoryComment $ + HistoryComment + { author = + Config.unAuthorName authorName, + commentId = (), + causal = causalHash, + createdAt, + authorThumbprint + } + let historyCommentRevision = + hashHistoryCommentRevision $ + HistoryCommentRevision + { revisionId = (), + subject, + content, + createdAt, + -- Hard coded for now, we can change this later if we want to support hiding comments + isHidden = False, + authorSignature = "", + comment = historyComment.commentId + } + let historyComment' = historyComment {causal = causalHashId} + let historyCommentRevisionHashBytes = + historyCommentRevision.revisionId + & unHistoryCommentRevisionHash + & Hash.toByteString + PK.PersonalKeySignature authorSignature <- + PK.signWithPersonalKey personalKey historyCommentRevisionHashBytes >>= \case + Left err -> Cli.returnEarly $ CommentFailed (Text.pack (show err)) + Right sig -> pure sig + Cli.runTransaction $ Q.commentOnCausal $ historyCommentRevision {comment = historyComment', authorSignature = authorSignature} Cli.respond $ CommentedSuccessfully where commentInstructions = diff --git a/unison-cli/src/Unison/Codebase/Editor/Output.hs b/unison-cli/src/Unison/Codebase/Editor/Output.hs index c6dcb18d79..08e1ea5079 100644 --- a/unison-cli/src/Unison/Codebase/Editor/Output.hs +++ b/unison-cli/src/Unison/Codebase/Editor/Output.hs @@ -30,7 +30,6 @@ import System.Exit (ExitCode) import U.Codebase.Branch.Diff (NameChanges) import U.Codebase.Config (ConfigKey) import U.Codebase.HashTags (CausalHash) -import U.Codebase.Sqlite.HistoryComment (HistoryComment) import U.Codebase.Sqlite.Project qualified as Sqlite import U.Codebase.Sqlite.ProjectBranch qualified as Sqlite import U.Codebase.Sqlite.ProjectReflog qualified as ProjectReflog @@ -62,6 +61,7 @@ import Unison.DeclCoherencyCheck (IncoherentDeclReason, IncoherentDeclReasons (. import Unison.Hash (Hash) import Unison.HashQualified qualified as HQ import Unison.HashQualifiedPrime qualified as HQ' +import Unison.HistoryComment (LatestHistoryComment) import Unison.LabeledDependency (LabeledDependency) import Unison.Merge qualified as Merge import Unison.Name (Name) @@ -136,8 +136,8 @@ data NumberedOutput History (Maybe Int) -- Amount of history to print HashLength - [(CausalHash, Maybe (HistoryComment () ()), Names.Diff)] - (Maybe (HistoryComment () ()), HistoryTail) -- 'origin point' of this view of history. + [(CausalHash, Maybe (LatestHistoryComment () () () ()), Names.Diff)] + (Maybe (LatestHistoryComment () () () ()), HistoryTail) -- 'origin point' of this view of history. | ListProjects [Sqlite.Project] | ListBranches ProjectName [(ProjectBranchName, [(URI, ProjectName, ProjectBranchName)])] | AmbiguousSwitch ProjectName (ProjectAndBranch ProjectName ProjectBranchName) @@ -478,6 +478,7 @@ data Output | InvalidCommentTarget Text | CommentedSuccessfully | CommentAborted + | CommentFailed Text | AuthorNameRequired | ConfigValueGet ConfigKey (Maybe Text) @@ -728,6 +729,7 @@ isFailure o = case o of InvalidCommentTarget {} -> True CommentedSuccessfully {} -> False CommentAborted {} -> True + CommentFailed {} -> True AuthorNameRequired {} -> True ConfigValueGet {} -> False diff --git a/unison-cli/src/Unison/Codebase/Transcript/Runner.hs b/unison-cli/src/Unison/Codebase/Transcript/Runner.hs index bc12d089ba..cc58f179a6 100644 --- a/unison-cli/src/Unison/Codebase/Transcript/Runner.hs +++ b/unison-cli/src/Unison/Codebase/Transcript/Runner.hs @@ -96,7 +96,7 @@ withRunner :: (Runner -> m r) -> m r withRunner isTest verbosity ucmVersion action = do - credMan <- AuthN.newCredentialManager + let credMan = AuthN.globalCredentialManager authenticatedHTTPClient <- initTranscriptAuthenticatedHTTPClient credMan -- If we're in a transcript test, configure the environment to use a non-existent fzf binary diff --git a/unison-cli/src/Unison/CommandLine/OutputMessages.hs b/unison-cli/src/Unison/CommandLine/OutputMessages.hs index 828d8da8b1..c0797ca5a2 100644 --- a/unison-cli/src/Unison/CommandLine/OutputMessages.hs +++ b/unison-cli/src/Unison/CommandLine/OutputMessages.hs @@ -41,7 +41,6 @@ import U.Codebase.Branch.Diff (NameChanges (..)) import U.Codebase.Config qualified as Config import U.Codebase.HashTags (CausalHash (..)) import U.Codebase.Reference qualified as Reference -import U.Codebase.Sqlite.HistoryComment (HistoryComment (..)) import U.Codebase.Sqlite.Project (Project (..)) import U.Codebase.Sqlite.ProjectBranch (ProjectBranch (..)) import U.Codebase.Sqlite.ProjectReflog qualified as ProjectReflog @@ -93,6 +92,7 @@ import Unison.Hash qualified as Hash import Unison.Hash32 (Hash32) import Unison.HashQualified qualified as HQ import Unison.HashQualifiedPrime qualified as HQ' +import Unison.HistoryComment (HistoryComment (..), HistoryCommentRevision (..), LatestHistoryComment) import Unison.LabeledDependency (LabeledDependency) import Unison.LabeledDependency qualified as LD import Unison.Merge (GUpdated (..), TwoWay (..)) @@ -307,10 +307,10 @@ notifyNumbered = \case reversedHistory = reverse history showNum :: Int -> Pretty showNum n = P.shown n <> ". " - displayComment :: Bool -> Maybe (HistoryComment () ()) -> [Pretty] + displayComment :: Bool -> Maybe (LatestHistoryComment () () () ()) -> [Pretty] displayComment prefixSpacer mayComment = case mayComment of Nothing -> [] - Just (HistoryComment {author, subject, content}) -> + Just (HistoryCommentRevision {comment = HistoryComment {author}, subject, content}) -> Monoid.whenM prefixSpacer [""] <> [(P.text "⊙ " <> P.bold (P.text author))] <> [ P.indent (P.blue " ┃ ") (P.text subject) @@ -2633,9 +2633,10 @@ notifyUser dir issueFn = \case prettyMain :: Pretty prettyMain = prettyName main - InvalidCommentTarget msg -> pure (P.wrap $ "Annotation failed, " <> P.text msg) + InvalidCommentTarget msg -> pure (P.wrap $ "Comment failed, " <> P.text msg) CommentedSuccessfully -> pure $ P.bold "Done." - CommentAborted -> pure (P.wrap "Annotation aborted.") + CommentAborted -> pure (P.wrap "Comment aborted.") + CommentFailed err -> pure (P.fatalCallout $ P.wrap $ "Comment failed, " <> P.text err) AuthorNameRequired -> pure $ P.hang "Please configure your a display name for your user." $ diff --git a/unison-cli/src/Unison/MCP/Cli.hs b/unison-cli/src/Unison/MCP/Cli.hs index 0a0ea51ffa..b660d22053 100644 --- a/unison-cli/src/Unison/MCP/Cli.hs +++ b/unison-cli/src/Unison/MCP/Cli.hs @@ -107,7 +107,7 @@ cliToMCP projCtx onError cli = do MCP.Env {ucmVersion, codebase, runtime, workDir} <- ask initialPP <- ExceptT . liftIO $ Codebase.runTransactionExceptT codebase $ do ppForProjectContext projCtx - credMan <- AuthN.newCredentialManager + let credMan = AuthN.globalCredentialManager let tokenProvider :: AuthN.TokenProvider tokenProvider = AuthN.newTokenProvider credMan authenticatedHTTPClient <- AuthN.newAuthenticatedHTTPClient tokenProvider ucmVersion diff --git a/unison-cli/src/Unison/Main.hs b/unison-cli/src/Unison/Main.hs index 37467c83b7..b37ebd4934 100644 --- a/unison-cli/src/Unison/Main.hs +++ b/unison-cli/src/Unison/Main.hs @@ -147,7 +147,7 @@ main version = do Text.putStrLn $ Text.pack progName <> " version: " <> Version.gitDescribeWithDate version MCPServer -> do let ucmVersion = Version.gitDescribeWithDate version - credMan <- AuthN.newCredentialManager + let credMan = AuthN.globalCredentialManager authenticatedHTTPClient <- initTranscriptAuthenticatedHTTPClient ucmVersion credMan getCodebaseOrExit mCodePathOption SC.DontLock (SC.MigrateAfterPrompt SC.Backup SC.Vacuum) \(_initRes, _, theCodebase) -> do withRuntimes RTI.Persistent \(runtime, sbRuntime) -> do @@ -183,7 +183,7 @@ main version = do let noOpCheckForChanges _ = pure () let serverUrl = Nothing let ucmVersion = Version.gitDescribeWithDate version - credMan <- liftIO $ AuthN.newCredentialManager + let credMan = AuthN.globalCredentialManager authenticatedHTTPClient <- initTranscriptAuthenticatedHTTPClient ucmVersion credMan startProjectPath <- Codebase.runTransaction theCodebase Codebase.expectCurrentProjectPath launch @@ -211,7 +211,7 @@ main version = do let noOpCheckForChanges _ = pure () let serverUrl = Nothing let ucmVersion = Version.gitDescribeWithDate version - credMan <- liftIO $ AuthN.newCredentialManager + let credMan = AuthN.globalCredentialManager authenticatedHTTPClient <- initTranscriptAuthenticatedHTTPClient ucmVersion credMan startProjectPath <- Codebase.runTransaction theCodebase Codebase.expectCurrentProjectPath launch @@ -328,7 +328,7 @@ main version = do void . Ki.fork scope $ LSP.spawnLsp lspFormattingConfig theCodebase runtime changeSignal let isTest = False let ucmVersion = Version.gitDescribeWithDate version - credMan <- liftIO $ AuthN.newCredentialManager + let credMan = AuthN.globalCredentialManager authenticatedHTTPClient <- initTranscriptAuthenticatedHTTPClient ucmVersion credMan mcpServerConfig <- MCP.initServer theCodebase runtime sbRuntime (Just currentDir) ucmVersion authenticatedHTTPClient Server.startServer diff --git a/unison-cli/unison-cli.cabal b/unison-cli/unison-cli.cabal index 1de4ab84bb..a7935bcf16 100644 --- a/unison-cli/unison-cli.cabal +++ b/unison-cli/unison-cli.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.36.0. +-- This file has been generated from package.yaml by hpack version 0.38.1. -- -- see: https://github.com/sol/hpack @@ -23,12 +23,9 @@ library Compat Stats System.Path - Unison.Auth.CredentialFile - Unison.Auth.CredentialManager Unison.Auth.Discovery Unison.Auth.HTTPClient Unison.Auth.Tokens - Unison.Auth.Types Unison.Auth.UserInfo Unison.Cli.DirectoryUtils Unison.Cli.DownloadUtils @@ -250,7 +247,6 @@ library , http-types , ki , lens - , lock-file , lsp >=2.2.0.0 , lsp-types >=2.0.2.0 , megaparsec @@ -287,6 +283,7 @@ library , unison-codebase-sqlite-hashing-v2 , unison-core , unison-core1 + , unison-credentials , unison-hash , unison-merge , unison-parser-typechecker diff --git a/unison-core/package.yaml b/unison-core/package.yaml index f63b7fa893..af06f1e5fe 100644 --- a/unison-core/package.yaml +++ b/unison-core/package.yaml @@ -10,6 +10,7 @@ library: ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-missing-pattern-synonym-signatures -funbox-strict-fields dependencies: - base + - aeson - bytestring - containers >= 0.6.3 - cryptonite @@ -22,12 +23,15 @@ library: - megaparsec - memory - mtl + - network-uri - nonempty-containers - rfc5051 - semialign - semigroups + - servant-client - text - text-builder + - time - these - transformers - unison-core diff --git a/unison-core/src/Unison/HistoryComment.hs b/unison-core/src/Unison/HistoryComment.hs new file mode 100644 index 0000000000..53e2b853ba --- /dev/null +++ b/unison-core/src/Unison/HistoryComment.hs @@ -0,0 +1,35 @@ +module Unison.HistoryComment + ( LatestHistoryComment, + HistoryComment (..), + HistoryCommentRevision (..), + ) +where + +import Data.ByteString (ByteString) +import Data.Text (Text) +import Data.Time.Clock (UTCTime) + +type LatestHistoryComment thumbprint causal revisionId commentId = + HistoryCommentRevision revisionId UTCTime (HistoryComment UTCTime thumbprint causal commentId) + +data HistoryComment createdAt thumbprint causal commentId = HistoryComment + { author :: Text, + -- The time the comment was created. + createdAt :: createdAt, + authorThumbprint :: thumbprint, + causal :: causal, + commentId :: commentId + } + deriving (Show, Eq, Functor) + +data HistoryCommentRevision revisionId createdAt comment = HistoryCommentRevision + { subject :: Text, + content :: Text, + createdAt :: createdAt, + -- The comment this is a revision for. + comment :: comment, + isHidden :: Bool, + authorSignature :: ByteString, + revisionId :: revisionId + } + deriving (Show, Eq, Functor) diff --git a/unison-core/src/Unison/KeyThumbprint.hs b/unison-core/src/Unison/KeyThumbprint.hs new file mode 100644 index 0000000000..706a646961 --- /dev/null +++ b/unison-core/src/Unison/KeyThumbprint.hs @@ -0,0 +1,6 @@ +module Unison.KeyThumbprint (KeyThumbprint (..)) where + +import Data.Text (Text) + +newtype KeyThumbprint = KeyThumbprint {thumbprintToText :: Text} + deriving (Show, Eq, Ord) diff --git a/parser-typechecker/src/Unison/Share/Types.hs b/unison-core/src/Unison/Share/Types.hs similarity index 100% rename from parser-typechecker/src/Unison/Share/Types.hs rename to unison-core/src/Unison/Share/Types.hs diff --git a/unison-core/unison-core1.cabal b/unison-core/unison-core1.cabal index 948360b616..d9e8dcd20c 100644 --- a/unison-core/unison-core1.cabal +++ b/unison-core/unison-core1.cabal @@ -1,6 +1,6 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.36.0. +-- This file has been generated from package.yaml by hpack version 0.38.1. -- -- see: https://github.com/sol/hpack @@ -33,6 +33,8 @@ library Unison.Hashable Unison.HashQualified Unison.HashQualifiedPrime + Unison.HistoryComment + Unison.KeyThumbprint Unison.Kind Unison.LabeledDependency Unison.Name @@ -50,6 +52,7 @@ library Unison.Referent Unison.ReferentPrime Unison.Settings + Unison.Share.Types Unison.Symbol Unison.Term Unison.Type @@ -97,7 +100,8 @@ library ViewPatterns ghc-options: -Wall -fno-warn-name-shadowing -fno-warn-missing-pattern-synonym-signatures -funbox-strict-fields build-depends: - base + aeson + , base , bytestring , containers >=0.6.3 , cryptonite @@ -110,13 +114,16 @@ library , megaparsec , memory , mtl + , network-uri , nonempty-containers , rfc5051 , semialign , semigroups + , servant-client , text , text-builder , these + , time , transformers , unison-core , unison-hash