From 4ec7aa71ac148062eb7e63d69d4bcccad852ef93 Mon Sep 17 00:00:00 2001 From: Paul Flynn Date: Mon, 19 Oct 2026 13:44:06 -0400 Subject: [PATCH] feat(sdk): add opt-in unprefixed manifest entry name Add WithUnprefixedManifestName option that writes the manifest under the zip entry name manifest.json (the name the TDF spec defines) instead of the legacy 0.manifest.json. Pre-4.3.0 WithTargetMode overrides this option and keeps the legacy name. Readers that predate manifest.json support, such as Go SDK releases before v0.35.0, cannot open TDFs created with this option. Restore deleted assertion in testGetManifestBeforeAndAfterFinalize that verifies GetManifest returns a clone. Update godoc for chunked and experimental options to reference compatibility caveats. Fix gocritic appendAssign lint issue by using bytes.Join instead of append. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Paul Flynn --- sdk/chunked_options.go | 13 ++++ sdk/chunked_test.go | 66 +++++++++++++--- sdk/chunked_writer.go | 26 ++++--- sdk/experimental/tdf/manifest.go | 3 +- sdk/experimental/tdf/options.go | 17 ++++ sdk/experimental/tdf/writer.go | 8 +- sdk/experimental/tdf/writer_test.go | 78 +++++++++++++++++++ sdk/internal/zipstream/segment_writer.go | 6 +- sdk/internal/zipstream/segment_writer_test.go | 44 +++++++++++ sdk/internal/zipstream/writer.go | 14 ++++ sdk/internal/zipstream/zip_headers.go | 11 ++- sdk/tdf.go | 10 ++- sdk/tdf_config.go | 13 ++++ sdk/tdf_test.go | 72 +++++++++++++++++ 14 files changed, 346 insertions(+), 35 deletions(-) diff --git a/sdk/chunked_options.go b/sdk/chunked_options.go index 9eee293a0c..91e40157b2 100644 --- a/sdk/chunked_options.go +++ b/sdk/chunked_options.go @@ -213,6 +213,19 @@ func WithChunkedTargetMode(mode string) ChunkedWriterOption { } } +// WithChunkedUnprefixedManifestName stores the manifest under the zip +// entry name manifest.json instead of the legacy 0.manifest.json. See +// WithUnprefixedManifestName for the compatibility caveat. A pre-4.3.0 +// WithChunkedTargetMode overrides this option. +// +// Experimental: not part of the stable SDK API; may change or be removed. +func WithChunkedUnprefixedManifestName() ChunkedWriterOption { + return func(c *chunkedWriterConfig) error { + c.unprefixedManifestName = true + return nil + } +} + // WithChunkedMimeType records the payload MIME type in the manifest. // // Experimental: not part of the stable SDK API; may change or be removed. diff --git a/sdk/chunked_test.go b/sdk/chunked_test.go index 76ed0118aa..5c5f16dc20 100644 --- a/sdk/chunked_test.go +++ b/sdk/chunked_test.go @@ -1017,9 +1017,9 @@ func (f *flakyArchiveWriter) WriteSegment(ctx context.Context, index int, size u // flakyArchiveWriterFactory returns a withChunkedArchiveWriterFactory // option whose archive fails the first failures writes to failIndex. func flakyArchiveWriterFactory(failIndex, failures int) ChunkedWriterOption { - return withChunkedArchiveWriterFactory(func(clock clock) zipstream.SegmentWriter { + return withChunkedArchiveWriterFactory(func(clock clock, opts ...zipstream.Option) zipstream.SegmentWriter { return &flakyArchiveWriter{ - SegmentWriter: defaultArchiveWriterFactory(clock), + SegmentWriter: defaultArchiveWriterFactory(clock, opts...), failIndex: failIndex, failures: failures, } @@ -1178,8 +1178,8 @@ func TestChunkedWriteSegmentCleansUpArchiveOnFailure(t *testing.T) { ctx := t.Context() s := newChunkedTestSDK(t) archive := &postWriteFailArchiveWriter{failIndex: 1, failures: 1} - writer, kasBundle := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock) zipstream.SegmentWriter { - archive.SegmentWriter = defaultArchiveWriterFactory(c) + writer, kasBundle := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock, opts ...zipstream.Option) zipstream.SegmentWriter { + archive.SegmentWriter = defaultArchiveWriterFactory(c, opts...) return archive })) @@ -1235,8 +1235,8 @@ func TestChunkedWriteSegmentCleansUpArchiveOnFailure(t *testing.T) { func TestChunkedCleanupRunsBeforeReleasingIndex(t *testing.T) { ctx := t.Context() archive := &postWriteFailArchiveWriter{failIndex: 1, failures: 1} - writer, _ := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock) zipstream.SegmentWriter { - archive.SegmentWriter = defaultArchiveWriterFactory(c) + writer, _ := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock, opts ...zipstream.Option) zipstream.SegmentWriter { + archive.SegmentWriter = defaultArchiveWriterFactory(c, opts...) return archive })) inner, ok := writer.(*chunkedWriter) @@ -1414,9 +1414,9 @@ func TestChunkedTrailerFailureFencesWriter(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { ctx := t.Context() - writer, _ := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock) zipstream.SegmentWriter { + writer, _ := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock, opts ...zipstream.Option) zipstream.SegmentWriter { return &trailerFailArchiveWriter{ - SegmentWriter: defaultArchiveWriterFactory(c), + SegmentWriter: defaultArchiveWriterFactory(c, opts...), failFinalize: tc.finalize, failClose: tc.close, } @@ -1482,9 +1482,9 @@ func (f *nonMutatingFinalizeArchiveWriter) Finalize(ctx context.Context, manifes func TestChunkedNonMutatingTrailerFailureKeepsWriterUsable(t *testing.T) { ctx := t.Context() s := newChunkedTestSDK(t) - writer, kasBundle := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock) zipstream.SegmentWriter { + writer, kasBundle := newChunkedWriterForTest(ctx, t, withChunkedArchiveWriterFactory(func(c clock, opts ...zipstream.Option) zipstream.SegmentWriter { return &nonMutatingFinalizeArchiveWriter{ - SegmentWriter: defaultArchiveWriterFactory(c), + SegmentWriter: defaultArchiveWriterFactory(c, opts...), failures: 1, } })) @@ -2486,9 +2486,9 @@ func (f *cleanupFailArchiveWriter) CleanupSegment(index int) error { func TestChunkedCleanupFailureFencesWriter(t *testing.T) { ctx := t.Context() writer, _ := newChunkedWriterForTest(ctx, t, - withChunkedArchiveWriterFactory(func(clock clock) zipstream.SegmentWriter { + withChunkedArchiveWriterFactory(func(clock clock, opts ...zipstream.Option) zipstream.SegmentWriter { return &cleanupFailArchiveWriter{ - SegmentWriter: defaultArchiveWriterFactory(clock), + SegmentWriter: defaultArchiveWriterFactory(clock, opts...), failIndex: 1, } })) @@ -2706,3 +2706,45 @@ func (c countingCipher) EncryptInPlace(data []byte) ([]byte, []byte, error) { *c.count++ return c.inner.EncryptInPlace(data) } + +func TestChunkedManifestEntryName(t *testing.T) { + for _, tc := range []struct { + name string + opts []ChunkedWriterOption + wantManifest string + }{ + {name: "default is 0.manifest.json", wantManifest: "0.manifest.json"}, + {name: "WithChunkedUnprefixedManifestName writes manifest.json", opts: []ChunkedWriterOption{WithChunkedUnprefixedManifestName()}, wantManifest: "manifest.json"}, + { + name: "legacy target mode overrides opt-in (opt-in first)", + opts: []ChunkedWriterOption{WithChunkedUnprefixedManifestName(), WithChunkedTargetMode("4.2.2")}, + wantManifest: "0.manifest.json", + }, + { + name: "legacy target mode overrides opt-in (opt-in second)", + opts: []ChunkedWriterOption{WithChunkedTargetMode("4.2.2"), WithChunkedUnprefixedManifestName()}, + wantManifest: "0.manifest.json", + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + s := newChunkedTestSDK(t) + writer, kasBundle := newChunkedWriterForTest(ctx, t, tc.opts...) + + body := writeChunkedSegments(ctx, t, writer, [][]byte{[]byte("payload")}) + fin, err := writer.Finalize(ctx) + require.NoError(t, err) + + tdfBytes := bytes.Join([][]byte{body, fin.Data}, nil) + zipReader, err := zip.NewReader(bytes.NewReader(tdfBytes), int64(len(tdfBytes))) + require.NoError(t, err) + + assertManifestName(t, zipReader.File, tc.wantManifest) + + // Verify LoadTDF round-trips + reader, err := s.LoadTDF(bytes.NewReader(tdfBytes), WithKasAllowlist([]string{kasBundle.url})) + require.NoError(t, err) + require.NotNil(t, reader) + }) + } +} diff --git a/sdk/chunked_writer.go b/sdk/chunked_writer.go index 8599ac0486..133064a66d 100644 --- a/sdk/chunked_writer.go +++ b/sdk/chunked_writer.go @@ -24,7 +24,7 @@ import ( // outside. For archiveWriterFactory it is the signature that closes the seam, // not the return type's implementability: a struct declared anywhere with the // right method set satisfies zipstream.SegmentWriter, but no code outside this -// package can spell `func(clock) zipstream.SegmentWriter` — clock is +// package can spell `func(clock, ...zipstream.Option) zipstream.SegmentWriter` — clock is // unexported and zipstream lives under internal/ — so a factory value cannot // be constructed to pass in. @@ -80,8 +80,9 @@ func defaultSegmentCipherFactory(dek []byte) (segmentCipher, error) { // archiveWriterFactory builds a zipstream.SegmentWriter for a new TDF. It // receives the writer's clock so ZIP header timestamps stay injectable -// end-to-end. -type archiveWriterFactory func(c clock) zipstream.SegmentWriter +// end-to-end, and options forwarded from the chunked layer. Implementations +// must pass opts through to zipstream.NewSegmentTDFWriter. +type archiveWriterFactory func(c clock, opts ...zipstream.Option) zipstream.SegmentWriter // defaultArchiveWriterFactory returns a ZIP64-enabled segment writer with its // clock plumbed to the caller-supplied clock. @@ -92,11 +93,9 @@ type archiveWriterFactory func(c clock) zipstream.SegmentWriter // consulted solely when no explicit segment order has been set, and // zipstream's Finalize always derives an order from the segments actually // present before it checks completeness. -func defaultArchiveWriterFactory(c clock) zipstream.SegmentWriter { - return zipstream.NewSegmentTDFWriter(1, - zipstream.WithZip64(), - zipstream.WithClock(c.Now), - ) +func defaultArchiveWriterFactory(c clock, opts ...zipstream.Option) zipstream.SegmentWriter { + archiveOpts := append([]zipstream.Option{zipstream.WithZip64(), zipstream.WithClock(c.Now)}, opts...) + return zipstream.NewSegmentTDFWriter(1, archiveOpts...) } // Sentinel errors returned by [ChunkedWriter]. @@ -403,6 +402,11 @@ type chunkedWriterConfig struct { // keyAccess is set. splitter KeySplitter + // unprefixedManifestName writes the manifest under the entry name + // manifest.json instead of the legacy 0.manifest.json. Ignored when + // useHex is set. Set by WithChunkedUnprefixedManifestName. + unprefixedManifestName bool + // useHex hex-encodes segment, root, and assertion signatures // before base64, producing the doubly-encoded form that readers // older than 4.3.0 require. Set by WithChunkedTargetMode. @@ -611,8 +615,12 @@ func newChunkedWriter(cfg chunkedWriterConfig) (*chunkedWriter, error) { if maxEncryptions == 0 { maxEncryptions = maxAESGCMSegmentsPerDEK } + var archiveOpts []zipstream.Option + if cfg.unprefixedManifestName && !cfg.useHex { + archiveOpts = append(archiveOpts, zipstream.WithUnprefixedManifestName()) + } return &chunkedWriter{ - archiveWriter: cfg.archiveFactory(cfg.clock), + archiveWriter: cfg.archiveFactory(cfg.clock, archiveOpts...), block: block, dek: dek, excludeVersion: cfg.excludeVersion, diff --git a/sdk/experimental/tdf/manifest.go b/sdk/experimental/tdf/manifest.go index cffcc7ddcf..b80675e1da 100644 --- a/sdk/experimental/tdf/manifest.go +++ b/sdk/experimental/tdf/manifest.go @@ -48,7 +48,8 @@ type ( EncryptionInformation = sdk.EncryptionInformation // Manifest is the TDF manifest, written to the archive as - // 0.manifest.json. + // 0.manifest.json by default, or manifest.json when + // [WithUnprefixedManifestName] is set. // // See [sdk.Manifest]. Manifest = sdk.Manifest diff --git a/sdk/experimental/tdf/options.go b/sdk/experimental/tdf/options.go index d674e43536..c8068801b9 100644 --- a/sdk/experimental/tdf/options.go +++ b/sdk/experimental/tdf/options.go @@ -139,6 +139,10 @@ type WriterConfig struct { // targetMode is the TDF spec version to write for, as semver. // Empty selects the current format. See WithTargetMode. targetMode string + + // unprefixedManifestName writes the manifest under manifest.json + // instead of 0.manifest.json. See WithUnprefixedManifestName. + unprefixedManifestName bool } // ReaderConfig contains configuration options for TDF Reader creation. @@ -413,6 +417,19 @@ func WithTargetMode(mode string) Option[*WriterConfig] { } } +// WithUnprefixedManifestName stores the manifest under the zip entry name +// manifest.json, the name the TDF spec defines, instead of the legacy +// 0.manifest.json written by default. +// +// Readers that predate manifest.json support, such as Go SDK releases before +// v0.35.0, can't open the result. A pre-4.3.0 [WithTargetMode] overrides +// this option. +func WithUnprefixedManifestName() Option[*WriterConfig] { + return func(c *WriterConfig) { + c.unprefixedManifestName = true + } +} + // WithAssertions includes cryptographic assertions in the TDF. // // Assertions provide additional integrity verification and can include diff --git a/sdk/experimental/tdf/writer.go b/sdk/experimental/tdf/writer.go index eda10e5a9a..83afc18564 100644 --- a/sdk/experimental/tdf/writer.go +++ b/sdk/experimental/tdf/writer.go @@ -137,12 +137,16 @@ func NewWriter(ctx context.Context, opts ...Option[*WriterConfig]) (*Writer, err return nil, fmt.Errorf("%w: %s", sdk.ErrUnsupportedSegmentIntegrityAlgorithm, config.segmentIntegrityAlg) } - inner, err := sdk.NewChunkedWriter(ctx, + chunkedOpts := []sdk.ChunkedWriterOption{ sdk.WithChunkedInitialAttributes(config.initialAttributes), sdk.WithChunkedDefaultKAS(config.initialDefaultKAS), sdk.WithChunkedKeySplitter(xorSplitter{}), sdk.WithChunkedTargetMode(config.targetMode), - ) + } + if config.unprefixedManifestName { + chunkedOpts = append(chunkedOpts, sdk.WithChunkedUnprefixedManifestName()) + } + inner, err := sdk.NewChunkedWriter(ctx, chunkedOpts...) if err != nil { return nil, err } diff --git a/sdk/experimental/tdf/writer_test.go b/sdk/experimental/tdf/writer_test.go index 23da5cfcea..2276fea281 100644 --- a/sdk/experimental/tdf/writer_test.go +++ b/sdk/experimental/tdf/writer_test.go @@ -3,6 +3,7 @@ package tdf import ( + "archive/zip" "bytes" "crypto/rand" "encoding/hex" @@ -63,6 +64,7 @@ func TestWriterEndToEnd(t *testing.T) { {"HybridXWingFlow", testHybridXWingFlow}, {"HybridP256MLKEM768Flow", testHybridP256MLKEM768Flow}, {"HybridP384MLKEM1024Flow", testHybridP384MLKEM1024Flow}, + {"ManifestEntryName", testManifestEntryName}, } for _, tc := range testCases { @@ -1226,3 +1228,79 @@ func testGetManifestBeforeAndAfterFinalize(t *testing.T) { // Ensure GetManifest returns a clone, not the same pointer assert.NotSame(t, fin.Manifest, m2) } + +// assertManifestName checks that exactly one manifest entry exists with the expected name. +func assertManifestName(t interface { + Errorf(string, ...interface{}) + Helper() +}, zipFiles []*zip.File, want string, +) { + t.Helper() + var hasManifest, hasPrefixed bool + for _, f := range zipFiles { + if f.Name == "manifest.json" { + hasManifest = true + } + if f.Name == "0.manifest.json" { + hasPrefixed = true + } + } + if want == "manifest.json" && !hasManifest { + t.Errorf("should have %s", want) + } + if want == "manifest.json" && hasPrefixed { + t.Errorf("should not have 0.manifest.json") + } + if want == "0.manifest.json" && !hasPrefixed { + t.Errorf("should have %s", want) + } + if want == "0.manifest.json" && hasManifest { + t.Errorf("should not have manifest.json") + } +} + +func testManifestEntryName(t *testing.T) { + for _, tc := range []struct { + name string + opts []Option[*WriterConfig] + wantManifest string + }{ + {name: "default is 0.manifest.json", wantManifest: "0.manifest.json"}, + {name: "WithUnprefixedManifestName writes manifest.json", opts: []Option[*WriterConfig]{WithUnprefixedManifestName()}, wantManifest: "manifest.json"}, + { + name: "legacy target mode overrides opt-in (opt-in first)", + opts: []Option[*WriterConfig]{WithUnprefixedManifestName(), WithTargetMode("4.2.2")}, + wantManifest: "0.manifest.json", + }, + { + name: "legacy target mode overrides opt-in (opt-in second)", + opts: []Option[*WriterConfig]{WithTargetMode("4.2.2"), WithUnprefixedManifestName()}, + wantManifest: "0.manifest.json", + }, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + writer, err := NewWriter(ctx, tc.opts...) + require.NoError(t, err) + + segResult, err := writer.WriteSegment(ctx, 0, []byte("payload")) + require.NoError(t, err) + segBytes, err := io.ReadAll(segResult.TDFData) + require.NoError(t, err) + + attributes := []*policy.Value{ + createTestAttribute("https://example.com/attr/Test/value/V", testKAS1, "kid1"), + } + finalizeResult, err := writer.Finalize(ctx, WithAttributeValues(attributes)) + require.NoError(t, err) + + // Assemble full TDF: segment bytes + finalize data + tdfBytes := bytes.Join([][]byte{segBytes, finalizeResult.Data}, nil) + + zipReader, err := zip.NewReader(bytes.NewReader(tdfBytes), int64(len(tdfBytes))) + require.NoError(t, err) + + assertManifestName(t, zipReader.File, tc.wantManifest) + }) + } +} diff --git a/sdk/internal/zipstream/segment_writer.go b/sdk/internal/zipstream/segment_writer.go index 420473a16d..5986c31ef1 100644 --- a/sdk/internal/zipstream/segment_writer.go +++ b/sdk/internal/zipstream/segment_writer.go @@ -267,8 +267,12 @@ func (sw *segmentWriter) Finalize(ctx context.Context, manifest []byte) ([]byte, sw.centralDir.AddFile(*sw.payloadEntry) // 3. Write manifest file (local header + data) + manifestName := TDFManifestFileName + if sw.config.UnprefixedManifestName { + manifestName = TDFManifestFileNameSpec + } manifestEntry := FileEntry{ - Name: TDFManifestFileName, + Name: manifestName, Offset: totalPayloadSize + uint64(buffer.Len()), // Offset from start of complete file Size: uint64(len(manifest)), CompressedSize: uint64(len(manifest)), diff --git a/sdk/internal/zipstream/segment_writer_test.go b/sdk/internal/zipstream/segment_writer_test.go index d08546d86e..0027e8a74e 100644 --- a/sdk/internal/zipstream/segment_writer_test.go +++ b/sdk/internal/zipstream/segment_writer_test.go @@ -1011,3 +1011,47 @@ func TestSegmentWriterNonMutatingFinalizeIsRetryable(t *testing.T) { require.NoError(t, err) assert.Equal(t, append(append([]byte{}, head...), payload...), got) } + +func TestSegmentWriter_ManifestEntryName(t *testing.T) { + for _, tc := range []struct { + name string + opts []Option + wantManifest string + }{ + {name: "default is 0.manifest.json", wantManifest: TDFManifestFileName}, + {name: "WithUnprefixedManifestName writes manifest.json", opts: []Option{WithUnprefixedManifestName()}, wantManifest: TDFManifestFileNameSpec}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := t.Context() + opts := append([]Option{WithZip64()}, tc.opts...) + writer := NewSegmentTDFWriter(1, opts...) + + payload := []byte("test payload") + segmentBytes, err := writer.WriteSegment(ctx, 0, uint64(len(payload)), crc32.ChecksumIEEE(payload)) + require.NoError(t, err) + + var allBytes []byte + allBytes = append(allBytes, segmentBytes...) + allBytes = append(allBytes, payload...) + + manifest := []byte(`{"test": true}`) + finalBytes, err := writer.Finalize(ctx, manifest) + require.NoError(t, err) + allBytes = append(allBytes, finalBytes...) + + zipReader, err := zip.NewReader(bytes.NewReader(allBytes), int64(len(allBytes))) + require.NoError(t, err) + require.Len(t, zipReader.File, 2, "should have payload and manifest") + + var manifestEntry *zip.File + for _, f := range zipReader.File { + if f.Name != TDFPayloadFileName { + manifestEntry = f + break + } + } + require.NotNil(t, manifestEntry, "manifest entry must be present") + assert.Equal(t, tc.wantManifest, manifestEntry.Name, "manifest entry name") + }) + } +} diff --git a/sdk/internal/zipstream/writer.go b/sdk/internal/zipstream/writer.go index e4c73f4437..6fcd36e44b 100644 --- a/sdk/internal/zipstream/writer.go +++ b/sdk/internal/zipstream/writer.go @@ -98,6 +98,10 @@ type Config struct { // zip field before the archive switches to ZIP64. Zero means // maxNonZip64Value (2 GiB - 1). See WithMaxNonZip64Value. MaxNonZip64Value uint64 + // UnprefixedManifestName writes the manifest under the zip entry name + // manifest.json instead of the legacy 0.manifest.json. Readers that + // predate manifest.json support can't open the result. + UnprefixedManifestName bool } // Option is a functional option for configuring writers @@ -162,6 +166,16 @@ func WithMaxNonZip64Value(maxValue uint64) Option { } } +// WithUnprefixedManifestName stores the manifest under the zip entry name +// manifest.json, the name the TDF spec defines, instead of the legacy +// 0.manifest.json written by default. Readers that predate manifest.json +// support, such as Go SDK releases before v0.35.0, can't open the result. +func WithUnprefixedManifestName() Option { + return func(c *Config) { + c.UnprefixedManifestName = true + } +} + // defaultConfig returns default configuration func defaultConfig() *Config { return &Config{ diff --git a/sdk/internal/zipstream/zip_headers.go b/sdk/internal/zipstream/zip_headers.go index ad7b036c8a..6e189cdcce 100644 --- a/sdk/internal/zipstream/zip_headers.go +++ b/sdk/internal/zipstream/zip_headers.go @@ -17,14 +17,13 @@ const ( ) const ( - // TDFManifestFileName is the manifest entry name this SDK writes. The 0. - // prefix is a holdover from an early design that anticipated several - // payload/manifest pairs per archive and never shipped. + // TDFManifestFileName is the legacy manifest entry name this SDK writes by + // default. The 0. prefix is a holdover from an early design that + // anticipated several payload/manifest pairs per archive and never shipped. TDFManifestFileName = "0.manifest.json" // TDFManifestFileNameSpec is the manifest entry name given by the OpenTDF - // spec: https://opentdf.io/spec#tdf-structure. Readers accept it so that - // archives from implementations written against the spec can be opened. - // The writer is unchanged and still emits TDFManifestFileName. + // spec: https://opentdf.io/spec#tdf-structure. Readers accept it, and the + // writer emits it when WithUnprefixedManifestName is set. // See https://github.com/opentdf/platform/issues/3513. TDFManifestFileNameSpec = "manifest.json" diff --git a/sdk/tdf.go b/sdk/tdf.go index 5d2003f585..3153c15703 100644 --- a/sdk/tdf.go +++ b/sdk/tdf.go @@ -358,7 +358,7 @@ func (s SDK) newTDFChunkedWriter(ctx context.Context, tdfConfig *TDFConfig, zipM } return newChunkedWriter(chunkedWriterConfig{ - archiveFactory: func(c clock) zipstream.SegmentWriter { + archiveFactory: func(c clock, opts ...zipstream.Option) zipstream.SegmentWriter { archiveOpts := []zipstream.Option{ zipstream.WithZip64Mode(zipMode), zipstream.WithClock(c.Now), @@ -366,11 +366,13 @@ func (s SDK) newTDFChunkedWriter(ctx context.Context, tdfConfig *TDFConfig, zipM if declaredSegments > 0 { archiveOpts = append(archiveOpts, zipstream.WithMaxSegments(declaredSegments)) } + archiveOpts = append(archiveOpts, opts...) return zipstream.NewSegmentTDFWriter(declaredSegments, archiveOpts...) }, - cipherFactory: defaultSegmentCipherFactory, - clock: systemClock{}, - dek: dek, + cipherFactory: defaultSegmentCipherFactory, + clock: systemClock{}, + dek: dek, + unprefixedManifestName: tdfConfig.unprefixedManifestName, // Set together, at construction, because they have to be: hex-then-base64 // signatures are written during WriteSegment, long before any Finalize // option is seen, and a manifest carrying one setting without the other diff --git a/sdk/tdf_config.go b/sdk/tdf_config.go index 7749c2ab91..c5bea7a871 100644 --- a/sdk/tdf_config.go +++ b/sdk/tdf_config.go @@ -153,6 +153,7 @@ type TDFConfig struct { splitPlan []keySplitStep preferredKeyWrapAlg ocrypto.KeyType useHex bool + unprefixedManifestName bool excludeVersionFromManifest bool addDefaultAssertion bool // maxSegmentEncryptions overrides maxAESGCMSegmentsPerDEK when non-zero. @@ -355,6 +356,18 @@ func WithTargetMode(mode string) TDFOption { } } +// WithUnprefixedManifestName stores the manifest under the zip entry name +// manifest.json, the name the TDF spec defines, instead of the legacy +// 0.manifest.json written by default. Readers that predate manifest.json +// support, such as Go SDK releases before v0.35.0, can't open the result. A +// pre-4.3.0 WithTargetMode overrides this option and keeps the legacy name. +func WithUnprefixedManifestName() TDFOption { + return func(c *TDFConfig) error { + c.unprefixedManifestName = true + return nil + } +} + // Schema Validation where 0 = none (skip), 1 = lax (allowing novel entries, 'falsy' values for unkowns), 2 = strict (rejecting novel entries, strict match to manifest schema) type SchemaValidationIntensity int diff --git a/sdk/tdf_test.go b/sdk/tdf_test.go index 49ae7ad383..45f8d4633e 100644 --- a/sdk/tdf_test.go +++ b/sdk/tdf_test.go @@ -1181,6 +1181,78 @@ func (s *TDFSuite) Test_TDFWithAssertion() { } } +// assertManifestName checks that exactly one manifest entry exists with the expected name. +func assertManifestName(t interface { + Errorf(string, ...interface{}) + Helper() +}, zipFiles []*zip.File, want string, +) { + t.Helper() + var hasManifest, hasPrefixed bool + for _, f := range zipFiles { + if f.Name == "manifest.json" { + hasManifest = true + } + if f.Name == "0.manifest.json" { + hasPrefixed = true + } + } + if want == "manifest.json" && !hasManifest { + t.Errorf("should have %s", want) + } + if want == "manifest.json" && hasPrefixed { + t.Errorf("should not have 0.manifest.json") + } + if want == "0.manifest.json" && !hasPrefixed { + t.Errorf("should have %s", want) + } + if want == "0.manifest.json" && hasManifest { + t.Errorf("should not have manifest.json") + } +} + +func (s *TDFSuite) Test_ManifestEntryName() { + for _, tc := range []struct { + name string + opts []TDFOption + wantManifest string + }{ + {name: "default stays prefixed", wantManifest: "0.manifest.json"}, + {name: "unprefixed opt-in", opts: []TDFOption{WithUnprefixedManifestName()}, wantManifest: "manifest.json"}, + { + name: "legacy target mode overrides opt-in (opt-in first)", + opts: []TDFOption{WithUnprefixedManifestName(), WithTargetMode("4.2.2")}, + wantManifest: "0.manifest.json", + }, + { + name: "legacy target mode overrides opt-in (opt-in second)", + opts: []TDFOption{WithTargetMode("4.2.2"), WithUnprefixedManifestName()}, + wantManifest: "0.manifest.json", + }, + } { + s.Run(tc.name, func() { + kasURL := s.kasTestURLLookup["https://a.kas/"] + opts := append([]TDFOption{WithKasInformation(KASInfo{URL: kasURL})}, tc.opts...) + + var buf bytes.Buffer + tdfObj, err := s.sdk.CreateTDF(&buf, bytes.NewReader([]byte("payload")), opts...) + s.Require().NoError(err) + + // Check ZIP namelist + zipReader, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len())) + s.Require().NoError(err) + + assertManifestName(s.T(), zipReader.File, tc.wantManifest) + + // Verify LoadTDF round-trips + r, err := s.sdk.LoadTDF(bytes.NewReader(buf.Bytes()), WithKasAllowlist([]string{kasURL})) + s.Require().NoError(err) + s.Require().NotNil(r) + s.Equal(tdfObj.Manifest().Policy, r.Manifest().Policy, "manifest should round-trip") + }) + } +} + func updateManifest(t *testing.T, tdfFile, outFile string, changer func(t *testing.T, dst io.Writer, f *zip.File) error) error { z, err := zip.OpenReader(tdfFile) if err != nil {