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 {