diff options
| author | 2026-06-07 10:19:14 +0000 | |
|---|---|---|
| committer | 2026-06-07 10:20:49 +0000 | |
| commit | 2f3b8b0dc29547ca7406d00de38d3e3b425240dd (patch) | |
| tree | ec9ae442ebb8f3d957e4404f20e06a262ab19286 /object | |
| parent | object/commit: Update to the new testgit (diff) | |
| signature | No signature | |
object/tag: Add
Diffstat (limited to 'object')
| -rw-r--r-- | object/parse.go | 3 | ||||
| -rw-r--r-- | object/tag/append.go | 53 | ||||
| -rw-r--r-- | object/tag/append_test.go | 76 | ||||
| -rw-r--r-- | object/tag/doc.go | 8 | ||||
| -rw-r--r-- | object/tag/malformed_test.go | 128 | ||||
| -rw-r--r-- | object/tag/parse.go | 143 | ||||
| -rw-r--r-- | object/tag/parse_test.go | 136 | ||||
| -rw-r--r-- | object/tag/roundtrip_test.go | 129 | ||||
| -rw-r--r-- | object/tag/tag.go | 25 | ||||
| -rw-r--r-- | object/tag/type.go | 10 |
10 files changed, 710 insertions, 1 deletions
diff --git a/object/parse.go b/object/parse.go index 6a60407c..bbede773 100644 --- a/object/parse.go +++ b/object/parse.go @@ -8,6 +8,7 @@ import ( "lindenii.org/go/furgit/object/commit" "lindenii.org/go/furgit/object/header" "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/tag" "lindenii.org/go/furgit/object/typ" ) @@ -46,7 +47,7 @@ func ParseWithoutHeader(ty typ.Type, body []byte, objectFormat id.ObjectFormat) case typ.TypeCommit: return commit.Parse(body, objectFormat) //nolint:wrapcheck case typ.TypeTag: - panic("TODO") + return tag.Parse(body, objectFormat) //nolint:wrapcheck case typ.TypeUnknown: return nil, typ.ErrInvalidType default: diff --git a/object/tag/append.go b/object/tag/append.go new file mode 100644 index 00000000..e3763ec7 --- /dev/null +++ b/object/tag/append.go @@ -0,0 +1,53 @@ +package tag + +import ( + "fmt" + + "lindenii.org/go/furgit/object/header" + "lindenii.org/go/furgit/object/typ" +) + +// AppendWithoutHeader renders the raw tag body bytes. +func (tag *Tag) AppendWithoutHeader(dst []byte) ([]byte, error) { + dst = fmt.Appendf(dst, "object %s\n", tag.TargetID.String()) + dst = append(dst, []byte("type ")...) + dst = append(dst, tag.TargetType.Name()...) + dst = append(dst, byte('\n')) + dst = append(dst, []byte("tag ")...) + dst = append(dst, tag.Name...) + dst = append(dst, byte('\n')) + dst = append(dst, []byte("tagger ")...) + + dst, err := tag.Tagger.Append(dst) + if err != nil { + return dst, fmt.Errorf("object/tag: append tagger: %w", err) + } + + dst = append(dst, byte('\n')) + + for _, h := range tag.ExtraHeaders { + // GIGO on empty keys and such. + dst = append(dst, []byte(h.Key)...) + dst = append(dst, byte(' ')) + dst = append(dst, h.Value...) + dst = append(dst, byte('\n')) + } + + dst = append(dst, byte('\n')) + dst = append(dst, tag.Message...) + + return dst, nil +} + +// AppendWithHeader renders the raw object (header + body). +func (tag *Tag) AppendWithHeader(dst []byte) ([]byte, error) { + // TODO: Try to not allocate? + body, err := tag.AppendWithoutHeader(nil) + if err != nil { + return dst, err + } + + dst = header.Append(dst, typ.TypeTag, uint64(len(body))) + + return append(dst, body...), nil +} diff --git a/object/tag/append_test.go b/object/tag/append_test.go new file mode 100644 index 00000000..b49d2e4b --- /dev/null +++ b/object/tag/append_test.go @@ -0,0 +1,76 @@ +package tag_test + +import ( + "bytes" + "strings" + "testing" + + "lindenii.org/go/furgit/internal/testgit" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signature" + "lindenii.org/go/furgit/object/tag" + "lindenii.org/go/furgit/object/typ" +) + +func TestAppendGitFsck(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat}) + if err != nil { + t.Fatalf("NewRepo: %v", err) + } + + blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("content\n")) + if err != nil { + t.Fatalf("HashObject(blob): %v", err) + } + + tagObject := &tag.Tag{ + TargetID: blobID, + TargetType: typ.TypeBlob, + Name: []byte("blob-tag"), + Tagger: signature.Signature{ + Name: []byte("Test Tagger"), + Email: []byte("tagger@example.org"), + WhenUnix: 1234567890, + OffsetMinutes: 0, + }, + Message: []byte("subject\n\nbody\n"), + } + + rawBody, err := tagObject.AppendWithoutHeader(nil) + if err != nil { + t.Fatalf("AppendWithoutHeader: %v", err) + } + + tagID, err := repo.HashObject(t, typ.TypeTag, bytes.NewReader(rawBody)) + if err != nil { + t.Fatalf("HashObject(tag): %v", err) + } + + err = repo.Fsck(t, testgit.FsckOptions{ + Strict: true, + NoDangling: true, + }, tagID) + if err != nil { + t.Fatalf("Fsck: %v", err) + } + + gitBody, err := repo.CatFile(t, typ.TypeTag, tagID) + if err != nil { + t.Fatalf("CatFile(tag): %v", err) + } + + parsed, err := tag.Parse(gitBody, objectFormat) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + assertTagEqual(t, parsed, tagObject) + }) + } +} diff --git a/object/tag/doc.go b/object/tag/doc.go new file mode 100644 index 00000000..5822de10 --- /dev/null +++ b/object/tag/doc.go @@ -0,0 +1,8 @@ +// Package tag provides parsed annotated tag objects and tag serialization. +// +// It parses annotated tags into ordinary Go values for reading and construction. +// It does not preserve the exact original byte layout +// needed for signature verification; +// callers that need signature-verification payload fidelity +// should use [lindenii.org/go/furgit/object/signed/tag]. +package tag diff --git a/object/tag/malformed_test.go b/object/tag/malformed_test.go new file mode 100644 index 00000000..2f51d027 --- /dev/null +++ b/object/tag/malformed_test.go @@ -0,0 +1,128 @@ +package tag_test + +import ( + "errors" + "strings" + "testing" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/tag" + refname "lindenii.org/go/furgit/ref/name" +) + +func TestParseMalformed(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + objectID := strings.Repeat("1", objectFormat.HexLen()) + shortID := strings.Repeat("2", objectFormat.HexLen()-2) + object := "object " + objectID + "\n" + typ := "type commit\n" + name := "tag v1\n" + + tagger := "tagger Test Tagger <tagger@example.org> 1234567890 +0000\n" + for _, tc := range []struct { + name string + body string + }{ + { + name: "empty", + body: "", + }, + { + name: "missing-object", + body: typ + name + tagger + "\nmessage\n", + }, + { + name: "malformed-object", + body: "object not-an-oid\n" + typ + name + tagger + "\nmessage\n", + }, + { + name: "short-object", + body: "object " + shortID + "\n" + typ + name + tagger + "\nmessage\n", + }, + { + name: "missing-type", + body: object + name + tagger + "\nmessage\n", + }, + { + name: "bad-type", + body: object + "type widget\n" + name + tagger + "\nmessage\n", + }, + { + name: "missing-tag", + body: object + typ + tagger + "\nmessage\n", + }, + { + name: "bad-tag-name", + body: object + typ + "tag bad tag\n" + tagger + "\nmessage\n", + }, + { + name: "missing-tagger", + body: object + typ + name + "\nmessage\n", + }, + { + name: "bad-tagger", + body: object + typ + name + "tagger Test Tagger <tagger@example.org> UTC\n\nmessage\n", + }, + { + name: "duplicate-object", + body: object + typ + name + tagger + object + "\nmessage\n", + }, + { + name: "duplicate-type", + body: object + typ + name + tagger + typ + "\nmessage\n", + }, + { + name: "duplicate-tag", + body: object + typ + name + tagger + name + "\nmessage\n", + }, + { + name: "duplicate-tagger", + body: object + typ + name + tagger + tagger + "\nmessage\n", + }, + { + name: "extra-header-without-space", + body: object + typ + name + tagger + "encoding\n\nmessage\n", + }, + { + name: "nonempty-message-without-blank-line", + body: object + typ + name + tagger + "message\n", + }, + { + name: "unterminated-signature-continuation", + body: object + typ + name + tagger + "gpgsig header\n continuation", + }, + { + name: "unterminated-extra-header", + body: object + typ + name + tagger + "encoding UTF-8", + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := tag.Parse([]byte(tc.body), objectFormat) + if !errors.Is(err, tag.ErrInvalidTag) { + t.Fatalf("Parse error = %v, want ErrInvalidTag", err) + } + }) + } + + t.Run("bad-tag-name-wraps-ref-name-error", func(t *testing.T) { + t.Parallel() + + _, err := tag.Parse([]byte(object+typ+"tag bad tag\n"+tagger+"\nmessage\n"), objectFormat) + if !errors.Is(err, tag.ErrInvalidTag) { + t.Fatalf("Parse error = %v, want ErrInvalidTag", err) + } + + if !errors.Is(err, refname.ErrInvalidName) { + t.Fatalf("Parse error = %v, want ErrInvalidName", err) + } + }) + }) + } +} diff --git a/object/tag/parse.go b/object/tag/parse.go new file mode 100644 index 00000000..c5ea7e14 --- /dev/null +++ b/object/tag/parse.go @@ -0,0 +1,143 @@ +package tag + +import ( + "bytes" + "errors" + "fmt" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signature" + "lindenii.org/go/furgit/object/typ" + refname "lindenii.org/go/furgit/ref/name" +) + +// ErrInvalidTag indicates a malformed tag object. +var ErrInvalidTag = errors.New("object/tag: invalid tag") + +// Parse decodes a tag object body. +func Parse(body []byte, objectFormat id.ObjectFormat) (*Tag, error) { + t := new(Tag) + + i := 0 + + var err error + + line, next, err := requiredHeaderLine(body, i, "object") + if err != nil { + return nil, err + } + + t.TargetID, err = objectFormat.FromString(string(line)) + if err != nil { + return nil, fmt.Errorf("%w: object: %w", ErrInvalidTag, err) + } + + i = next + + line, next, err = requiredHeaderLine(body, i, "type") + if err != nil { + return nil, err + } + + t.TargetType, err = typ.Parse(string(line)) + if err != nil { + return nil, fmt.Errorf("%w: type: %w", ErrInvalidTag, err) + } + + i = next + + line, next, err = requiredHeaderLine(body, i, "tag") + if err != nil { + return nil, err + } + + _, err = refname.Tag(string(line)) + if err != nil { + return nil, fmt.Errorf("%w: tag name: %w", ErrInvalidTag, err) + } + + t.Name = append([]byte(nil), line...) + i = next + + line, next, err = requiredHeaderLine(body, i, "tagger") + if err != nil { + return nil, err + } + + tagger, err := signature.Parse(line) + if err != nil { + return nil, fmt.Errorf("%w: tagger: %w", ErrInvalidTag, err) + } + + t.Tagger = *tagger + i = next + + for i < len(body) { + lineStart := i + + rel := bytes.IndexByte(body[i:], '\n') + if rel < 0 { + return nil, fmt.Errorf("%w: unterminated header line at offset %d", ErrInvalidTag, lineStart) + } + + line := body[i : i+rel] + i += rel + 1 + + if len(line) == 0 { + t.Message = append([]byte(nil), body[i:]...) + + return t, nil + } + + key, value, found := bytes.Cut(line, []byte{' '}) + if !found { + return nil, fmt.Errorf("%w: header line at offset %d has no ' ' separator", ErrInvalidTag, lineStart) + } + + switch string(key) { + case "object", "type", "tag", "tagger": + return nil, fmt.Errorf("%w: unexpected %s header at offset %d", ErrInvalidTag, key, lineStart) + case "gpgsig", "gpgsig-sha256": + for i < len(body) { + nextRel := bytes.IndexByte(body[i:], '\n') + if nextRel < 0 { + return nil, fmt.Errorf("%w: unterminated signature header at offset %d", ErrInvalidTag, i) + } + + if body[i] != ' ' { + break + } + + i += nextRel + 1 + } + default: + t.ExtraHeaders = append(t.ExtraHeaders, ExtraHeader{ + Key: string(key), + Value: append([]byte(nil), value...), + }) + } + } + + return t, nil +} + +func requiredHeaderLine(body []byte, offset int, want string) ([]byte, int, error) { + rel := bytes.IndexByte(body[offset:], '\n') + if rel < 0 { + return nil, offset, fmt.Errorf("%w: unterminated %s header at offset %d", ErrInvalidTag, want, offset) + } + + line := body[offset : offset+rel] + next := offset + rel + 1 + + key, value, found := bytes.Cut(line, []byte{' '}) + if !found { + return nil, offset, fmt.Errorf("%w: %s header at offset %d has no ' ' separator", ErrInvalidTag, want, offset) + } + + if string(key) != want { + return nil, offset, fmt.Errorf("%w: expected %s header at offset %d, got %s", ErrInvalidTag, want, offset, key) + } + + return value, next, nil +} diff --git a/object/tag/parse_test.go b/object/tag/parse_test.go new file mode 100644 index 00000000..423fea30 --- /dev/null +++ b/object/tag/parse_test.go @@ -0,0 +1,136 @@ +package tag_test + +import ( + "bytes" + "strings" + "testing" + + "lindenii.org/go/furgit/internal/testgit" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/tag" + "lindenii.org/go/furgit/object/typ" +) + +func TestParse(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat}) + if err != nil { + t.Fatalf("NewRepo: %v", err) + } + + blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("content\n")) + if err != nil { + t.Fatalf("HashObject(blob): %v", err) + } + + treeID, err := repo.MkTree(t, []testgit.MkTreeEntry{ + {Mode: "100644", Type: typ.TypeBlob, OID: blobID, Name: "file.txt"}, + }) + if err != nil { + t.Fatalf("MkTree: %v", err) + } + + commitID, err := repo.CommitTree(t, treeID, testgit.CommitTreeOptions{ + Message: "tag target subject\n\nbody", + Author: testgit.Identity{ + Name: "Target Author", + Email: "target-author@example.org", + }, + Committer: testgit.Identity{ + Name: "Target Committer", + Email: "target-committer@example.org", + }, + AuthorDate: "1234567890 +0000", + CommitterDate: "1234567891 +0000", + }) + if err != nil { + t.Fatalf("CommitTree: %v", err) + } + + tagID, err := repo.TagAnnotated(t, "v1.2.3", commitID, testgit.TagAnnotatedOptions{ + Message: "tag subject\n\ntag body", + Tagger: testgit.Identity{ + Name: "Test Tagger", + Email: "tagger@example.org", + }, + TaggerDate: "1234567999 -0330", + }) + if err != nil { + t.Fatalf("TagAnnotated: %v", err) + } + + rawBody, err := repo.CatFile(t, typ.TypeTag, tagID) + if err != nil { + t.Fatalf("CatFile: %v", err) + } + + parsed, err := tag.Parse(rawBody, objectFormat) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if parsed.TargetID != commitID { + t.Fatalf("target id = %s, want %s", parsed.TargetID, commitID) + } + + if parsed.TargetType != typ.TypeCommit { + t.Fatalf("target type = %v, want %v", parsed.TargetType, typ.TypeCommit) + } + + if !bytes.Equal(parsed.Name, []byte("v1.2.3")) { + t.Fatalf("name = %q, want %q", parsed.Name, "v1.2.3") + } + + if !bytes.Equal(parsed.Tagger.Name, []byte("Test Tagger")) { + t.Fatalf("tagger name = %q, want %q", parsed.Tagger.Name, "Test Tagger") + } + + if !bytes.Equal(parsed.Tagger.Email, []byte("tagger@example.org")) { + t.Fatalf("tagger email = %q, want %q", parsed.Tagger.Email, "tagger@example.org") + } + + if parsed.Tagger.WhenUnix != 1234567999 { + t.Fatalf("tagger time = %d, want %d", parsed.Tagger.WhenUnix, int64(1234567999)) + } + + if parsed.Tagger.OffsetMinutes != -210 { + t.Fatalf("tagger offset = %d, want %d", parsed.Tagger.OffsetMinutes, int32(-210)) + } + + if !bytes.Equal(parsed.Message, []byte("tag subject\n\ntag body\n")) { + t.Fatalf("message = %q, want %q", parsed.Message, "tag subject\n\ntag body\n") + } + }) + } +} + +func TestParseEmptyMessageWithoutBlankLine(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + objectID := strings.Repeat("1", objectFormat.HexLen()) + body := "" + + "object " + objectID + "\n" + + "type commit\n" + + "tag empty-message-tag\n" + + "tagger Test Tagger <tagger@example.org> 1234567890 +0000\n" + + got, err := tag.Parse([]byte(body), objectFormat) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + if len(got.Message) != 0 { + t.Fatalf("message = %q, want empty", got.Message) + } + }) + } +} diff --git a/object/tag/roundtrip_test.go b/object/tag/roundtrip_test.go new file mode 100644 index 00000000..8e754854 --- /dev/null +++ b/object/tag/roundtrip_test.go @@ -0,0 +1,129 @@ +package tag_test + +import ( + "bytes" + "slices" + "strings" + "testing" + + "lindenii.org/go/furgit/internal/testgit" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signature" + "lindenii.org/go/furgit/object/tag" + "lindenii.org/go/furgit/object/typ" +) + +func TestRoundTrip(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat}) + if err != nil { + t.Fatalf("NewRepo: %v", err) + } + + blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("roundtrip\n")) + if err != nil { + t.Fatalf("HashObject(blob): %v", err) + } + + want := &tag.Tag{ + TargetID: blobID, + TargetType: typ.TypeBlob, + Name: []byte("roundtrip-tag"), + Tagger: signature.Signature{ + Name: []byte("Round Trip Tagger"), + Email: []byte("roundtrip-tagger@example.org"), + WhenUnix: 1234567999, + OffsetMinutes: 330, + }, + Message: []byte("roundtrip subject\n\nroundtrip body\n\n"), + ExtraHeaders: []tag.ExtraHeader{ + {Key: "encoding", Value: []byte("UTF-8")}, + {Key: "x-test-header", Value: []byte("value")}, + }, + } + + rawBody, err := want.AppendWithoutHeader(nil) + if err != nil { + t.Fatalf("AppendWithoutHeader: %v", err) + } + + roundTripID, err := repo.HashObject(t, typ.TypeTag, bytes.NewReader(rawBody)) + if err != nil { + t.Fatalf("HashObject(tag): %v", err) + } + + err = repo.Fsck(t, testgit.FsckOptions{ + Strict: true, + NoDangling: true, + }, roundTripID) + if err != nil { + t.Fatalf("Fsck: %v", err) + } + + gitBody, err := repo.CatFile(t, typ.TypeTag, roundTripID) + if err != nil { + t.Fatalf("CatFile: %v", err) + } + + got, err := tag.Parse(gitBody, objectFormat) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + assertTagEqual(t, got, want) + }) + } +} + +func assertTagEqual(t *testing.T, got *tag.Tag, want *tag.Tag) { + t.Helper() + + if got.TargetID != want.TargetID { + t.Fatalf("target id = %s, want %s", got.TargetID, want.TargetID) + } + + if got.TargetType != want.TargetType { + t.Fatalf("target type = %v, want %v", got.TargetType, want.TargetType) + } + + if !bytes.Equal(got.Name, want.Name) { + t.Fatalf("name = %q, want %q", got.Name, want.Name) + } + + assertSignatureEqual(t, "tagger", got.Tagger, want.Tagger) + + if !bytes.Equal(got.Message, want.Message) { + t.Fatalf("message = %q, want %q", got.Message, want.Message) + } + + if !slices.EqualFunc(got.ExtraHeaders, want.ExtraHeaders, func(gotHeader tag.ExtraHeader, wantHeader tag.ExtraHeader) bool { + return gotHeader.Key == wantHeader.Key && bytes.Equal(gotHeader.Value, wantHeader.Value) + }) { + t.Fatalf("extra headers = %+v, want %+v", got.ExtraHeaders, want.ExtraHeaders) + } +} + +func assertSignatureEqual(t *testing.T, name string, got signature.Signature, want signature.Signature) { + t.Helper() + + if !bytes.Equal(got.Name, want.Name) { + t.Fatalf("%s name = %q, want %q", name, got.Name, want.Name) + } + + if !bytes.Equal(got.Email, want.Email) { + t.Fatalf("%s email = %q, want %q", name, got.Email, want.Email) + } + + if got.WhenUnix != want.WhenUnix { + t.Fatalf("%s time = %d, want %d", name, got.WhenUnix, want.WhenUnix) + } + + if got.OffsetMinutes != want.OffsetMinutes { + t.Fatalf("%s offset = %d, want %d", name, got.OffsetMinutes, want.OffsetMinutes) + } +} diff --git a/object/tag/tag.go b/object/tag/tag.go new file mode 100644 index 00000000..f4b36c30 --- /dev/null +++ b/object/tag/tag.go @@ -0,0 +1,25 @@ +package tag + +import ( + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signature" + "lindenii.org/go/furgit/object/typ" +) + +// Tag represents a fully materialized Git annotated tag object. +// +// Labels: MT-Unsafe. +type Tag struct { + TargetID id.ObjectID + TargetType typ.Type + Name []byte + Tagger signature.Signature + Message []byte + ExtraHeaders []ExtraHeader +} + +// ExtraHeader represents an extra header in a Git tag object. +type ExtraHeader struct { + Key string + Value []byte +} diff --git a/object/tag/type.go b/object/tag/type.go new file mode 100644 index 00000000..0987a6c1 --- /dev/null +++ b/object/tag/type.go @@ -0,0 +1,10 @@ +package tag + +import "lindenii.org/go/furgit/object/typ" + +// ObjectType returns TypeTag. +func (tag *Tag) ObjectType() typ.Type { + _ = tag + + return typ.TypeTag +} |
