Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions packages/code-storage-go/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,18 +554,25 @@ func (r *Repo) GetCommit(ctx context.Context, options GetCommitOptions) (GetComm
return GetCommitResult{}, err
}

return GetCommitResult{
Commit: CommitInfo{
SHA: payload.Commit.SHA,
Message: payload.Commit.Message,
AuthorName: payload.Commit.AuthorName,
AuthorEmail: payload.Commit.AuthorEmail,
CommitterName: payload.Commit.CommitterName,
CommitterEmail: payload.Commit.CommitterEmail,
Date: parseTime(payload.Commit.Date),
RawDate: payload.Commit.Date,
},
}, nil
commit := CommitInfo{
SHA: payload.Commit.SHA,
Message: payload.Commit.Message,
AuthorName: payload.Commit.AuthorName,
AuthorEmail: payload.Commit.AuthorEmail,
CommitterName: payload.Commit.CommitterName,
CommitterEmail: payload.Commit.CommitterEmail,
Date: parseTime(payload.Commit.Date),
RawDate: payload.Commit.Date,
}
// Only surface these for signed commits, which carry both the armored
// signature and the signed payload. If either is missing the commit is
// treated as unsigned and neither field is set.
if payload.Commit.Signature != "" && payload.Commit.Payload != "" {
commit.Signature = payload.Commit.Signature
commit.Payload = payload.Commit.Payload
}

return GetCommitResult{Commit: commit}, nil
}

// GetBlame returns per-line authorship for a file at a ref.
Expand Down
54 changes: 53 additions & 1 deletion packages/code-storage-go/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,30 @@ func TestRemoteURLRefs(t *testing.T) {
}
})

t.Run("includes verify-sig op in refs claim", func(t *testing.T) {
remote, err := repo.RemoteURL(nil, RemoteURLOptions{
RefPolicies: RefPolicyList{
{Pattern: "refs/heads/main", Ops: Ops{OpVerifySig}},
},
})
if err != nil {
t.Fatalf("remote url error: %v", err)
}
claims := parseJWTFromURL(t, remote)
refs, ok := claims["refs"].([]interface{})
if !ok || len(refs) != 1 {
t.Fatalf("expected 1 ref rule, got %v", claims["refs"])
}
rule, ok := refs[0].([]interface{})
if !ok || len(rule) != 2 {
t.Fatalf("unexpected rule shape: %v", refs[0])
}
ops, ok := rule[1].([]interface{})
if !ok || len(ops) != 1 || ops[0] != "verify-sig" {
t.Fatalf("unexpected ops: %v", rule[1])
}
})

t.Run("omits refs from JWT when not provided", func(t *testing.T) {
remote, err := repo.RemoteURL(nil, RemoteURLOptions{})
if err != nil {
Expand Down Expand Up @@ -1938,7 +1962,7 @@ func TestGetCommit(t *testing.T) {
t.Fatalf("unexpected sha query: %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"feat: add endpoint","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z"}}`))
_, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"feat: add endpoint","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z","signature":"-----BEGIN PGP SIGNATURE-----\nABC\n-----END PGP SIGNATURE-----\n","payload":"tree deadbeef\nauthor Jane Doe <jane@example.com> 1700000000 +0000\n"}}`))
}))
defer server.Close()

Expand All @@ -1964,6 +1988,34 @@ func TestGetCommit(t *testing.T) {
if result.Commit.RawDate != "2024-01-15T14:32:18Z" || result.Commit.Date.IsZero() {
t.Fatalf("unexpected date: %+v", result.Commit)
}
if !strings.Contains(result.Commit.Signature, "BEGIN PGP SIGNATURE") {
t.Fatalf("unexpected signature: %q", result.Commit.Signature)
}
if !strings.HasPrefix(result.Commit.Payload, "tree deadbeef") {
t.Fatalf("unexpected payload: %q", result.Commit.Payload)
}
}

func TestGetCommitUnsigned(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"commit":{"sha":"abc123","message":"chore: noop","author_name":"Jane Doe","author_email":"jane@example.com","committer_name":"Jane Doe","committer_email":"jane@example.com","date":"2024-01-15T14:32:18Z"}}`))
}))
defer server.Close()

client, err := NewClient(Options{Name: "acme", Key: testKey, APIBaseURL: server.URL})
if err != nil {
t.Fatalf("client error: %v", err)
}
repo := &Repo{ID: "repo", DefaultBranch: "main", client: client}

result, err := repo.GetCommit(nil, GetCommitOptions{SHA: "abc123"})
if err != nil {
t.Fatalf("get commit error: %v", err)
}
if result.Commit.Signature != "" || result.Commit.Payload != "" {
t.Fatalf("expected empty signature/payload for unsigned commit, got %+v", result.Commit)
}
}

func TestGetCommitRequiresSHA(t *testing.T) {
Expand Down
17 changes: 13 additions & 4 deletions packages/code-storage-go/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type fileWithMetadataRaw struct {
Mode string `json:"mode"`
Size int64 `json:"size"`
LastCommitSHA string `json:"last_commit_sha"`
Type string `json:"type,omitempty"`
Type string `json:"type"`
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

omitempty is redundant on the unmarshalling side

}

type commitMetadataRaw struct {
Expand Down Expand Up @@ -56,7 +56,7 @@ type listCommitsResponse struct {
}

type getCommitResponse struct {
Commit commitInfoRaw `json:"commit"`
Commit commitInfoWithSignatureRaw `json:"commit"`
}

type blameResponse struct {
Expand All @@ -71,7 +71,7 @@ type blameLineRaw struct {
CommitSHA string `json:"commit_sha"`
OriginalLineNumber int32 `json:"original_line_number"`
OriginalPath string `json:"original_path"`
PreviousCommitSHA string `json:"previous_commit_sha,omitempty"`
PreviousCommitSHA string `json:"previous_commit_sha"`
AuthorName string `json:"author_name"`
AuthorEmail string `json:"author_email"`
AuthorTime string `json:"author_time"`
Expand All @@ -91,6 +91,15 @@ type commitInfoRaw struct {
Date string `json:"date"`
}

// commitInfoWithSignatureRaw extends commitInfoRaw with the signature details
// the single-commit endpoint returns for signed commits. Both fields are
// omitted for unsigned commits.
type commitInfoWithSignatureRaw struct {
commitInfoRaw
Signature string `json:"signature"`
Payload string `json:"payload"`
}

type listReposResponse struct {
Repos []repoInfoRaw `json:"repos"`
NextCursor string `json:"next_cursor"`
Expand Down Expand Up @@ -185,7 +194,7 @@ type mergeResponse struct {
TreeSHA string `json:"tree_sha"`
Source mergeSourceRaw `json:"source"`
Target mergeTargetRaw `json:"target"`
MergeBaseSHA string `json:"merge_base_sha,omitempty"`
MergeBaseSHA string `json:"merge_base_sha"`
PromotedCommits int `json:"promoted_commits"`
}

Expand Down
15 changes: 14 additions & 1 deletion packages/code-storage-go/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ type Op = string
const (
OpNoForcePush Op = "no-force-push"
OpNoPush Op = "no-push"
// OpVerifySig requires every commit introduced by a push to a matching ref
// to carry a valid signature from a registered signing key.
OpVerifySig Op = "verify-sig"
)

// Ops is a list of policy operations.
Expand Down Expand Up @@ -551,6 +554,15 @@ type CommitInfo struct {
CommitterEmail string
Date time.Time
RawDate string
// Signature is the armored OpenPGP/SSH signature from the commit's gpgsig
// header. Only populated by GetCommit for signed commits. Always empty for
// ListCommits entries and unsigned commits.
Signature string
// Payload is the exact bytes the signature is computed over (the raw commit
// object with the gpgsig header removed). Only populated by GetCommit for
// signed commits. Always empty for ListCommits entries and unsigned
// commits.
Payload string
}

// ListCommitsResult describes commits list.
Expand All @@ -566,7 +578,8 @@ type GetCommitOptions struct {
SHA string
}

// GetCommitResult is the result returned by Repo.GetCommit.
// GetCommitResult is the result returned by Repo.GetCommit. For signed commits
// the returned CommitInfo carries the armored Signature and signed Payload.
type GetCommitResult struct {
Commit CommitInfo
}
Expand Down
2 changes: 1 addition & 1 deletion packages/code-storage-go/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package storage

const (
PackageName = "code-storage-go-sdk"
PackageVersion = "0.9.0"
PackageVersion = "0.10.0"
)

func userAgent() string {
Expand Down
5 changes: 5 additions & 0 deletions packages/code-storage-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ print(commits["commits"])
# Get a single commit's metadata (no diff)
result = await repo.get_commit(sha="abc123...")
print(result["commit"]["message"], result["commit"]["author_name"])
# Signed commits also include the armored signature plus the exact signed
# payload (raw commit object minus the gpgsig header) so you can verify it
# yourself. Both keys are absent for unsigned commits.
if "signature" in result["commit"]:
print(result["commit"]["signature"], result["commit"]["payload"])

# Blame a file (per-line authorship). The top-level "commit_sha" is the SHA the
# `ref` resolved to; each entry in "lines" carries its own author/committer
Expand Down
2 changes: 2 additions & 0 deletions packages/code-storage-python/pierre_storage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from pierre_storage.types import (
OP_NO_FORCE_PUSH,
OP_NO_PUSH,
OP_VERIFY_SIG,
BaseRepo,
BlameLine,
BlameResult,
Expand Down Expand Up @@ -101,6 +102,7 @@
"Op",
"OP_NO_FORCE_PUSH",
"OP_NO_PUSH",
"OP_VERIFY_SIG",
"Ops",
"RefPolicy",
"Refs",
Expand Down
9 changes: 9 additions & 0 deletions packages/code-storage-python/pierre_storage/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -1390,6 +1390,15 @@ async def get_commit(
"date": date,
"raw_date": commit_raw["date"],
}
# Only surface these for signed commits, which carry both the
# armored signature and the signed payload. If either is missing
# (absent/null/empty) the commit is treated as unsigned and
# neither key is attached.
signature = commit_raw.get("signature")
payload = commit_raw.get("payload")
if signature and payload:
commit["signature"] = signature
commit["payload"] = payload
return {"commit": commit}

async def get_blame(
Expand Down
19 changes: 17 additions & 2 deletions packages/code-storage-python/pierre_storage/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ class GitStorageOptions(TypedDict, total=False):

OP_NO_FORCE_PUSH: Op = "no-force-push"
OP_NO_PUSH: Op = "no-push"
# Requires every commit introduced by a push to a matching ref to carry a valid
# signature from a registered signing key.
OP_VERIFY_SIG: Op = "verify-sig"

# Ops is a list of policy operations.
Ops = List[Op]
Expand Down Expand Up @@ -305,7 +308,14 @@ class DeleteBranchResult(TypedDict):


class CommitInfo(TypedDict):
"""Information about a commit."""
"""Information about a commit.

``signature`` and ``payload`` are populated only by ``get_commit`` for
signed commits (``signature`` is the armored OpenPGP/SSH block from the
commit's gpgsig header; ``payload`` is the exact bytes the signature is
computed over). Both keys are absent for list-commits entries and for
unsigned commits.
"""

sha: str
message: str
Expand All @@ -315,6 +325,8 @@ class CommitInfo(TypedDict):
committer_email: str
date: datetime
raw_date: str
signature: NotRequired[str]
payload: NotRequired[str]


class ListCommitsResult(TypedDict):
Expand All @@ -326,7 +338,10 @@ class ListCommitsResult(TypedDict):


class GetCommitResult(TypedDict):
"""Result from fetching metadata for a single commit."""
"""Result from fetching metadata for a single commit.

For signed commits, ``commit`` carries ``signature`` and ``payload``.
"""

commit: CommitInfo

Expand Down
2 changes: 1 addition & 1 deletion packages/code-storage-python/pierre_storage/version.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Version information for Pierre Storage SDK."""

PACKAGE_NAME = "code-storage-py-sdk"
PACKAGE_VERSION = "1.10.0"
PACKAGE_VERSION = "1.11.0"


def get_user_agent() -> str:
Expand Down
2 changes: 1 addition & 1 deletion packages/code-storage-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "pierre-storage"
version = "1.10.0"
version = "1.11.0"
description = "Pierre Git Storage SDK for Python"
readme = "README.md"
license = "MIT"
Expand Down
14 changes: 14 additions & 0 deletions packages/code-storage-python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,20 @@ def test_generate_jwt_with_refs(self, test_key: str) -> None:
]
assert "ops" not in payload

def test_generate_jwt_with_verify_sig_ref(self, test_key: str) -> None:
"""Test JWT generation with the verify-sig ref policy op."""
from pierre_storage import OP_VERIFY_SIG

token = generate_jwt(
key_pem=test_key,
issuer="test-customer",
repo_id="test-repo",
refs=[{"pattern": "refs/heads/main", "ops": [OP_VERIFY_SIG]}],
)

payload = jwt.decode(token, options={"verify_signature": False})
assert payload["refs"] == [["refs/heads/main", ["verify-sig"]]]

def test_generate_jwt_without_refs(self, test_key: str) -> None:
"""Test JWT generation omits refs when not provided."""
token = generate_jwt(
Expand Down
Loading
Loading