Skip to content
Open
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
13 changes: 13 additions & 0 deletions sdk/chunked_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
66 changes: 54 additions & 12 deletions sdk/chunked_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
}))

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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,
}
}))
Expand Down Expand Up @@ -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,
}
}))
Expand Down Expand Up @@ -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)
})
}
}
26 changes: 17 additions & 9 deletions sdk/chunked_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand All @@ -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].
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion sdk/experimental/tdf/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions sdk/experimental/tdf/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions sdk/experimental/tdf/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
78 changes: 78 additions & 0 deletions sdk/experimental/tdf/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package tdf

import (
"archive/zip"
"bytes"
"crypto/rand"
"encoding/hex"
Expand Down Expand Up @@ -63,6 +64,7 @@ func TestWriterEndToEnd(t *testing.T) {
{"HybridXWingFlow", testHybridXWingFlow},
{"HybridP256MLKEM768Flow", testHybridP256MLKEM768Flow},
{"HybridP384MLKEM1024Flow", testHybridP384MLKEM1024Flow},
{"ManifestEntryName", testManifestEntryName},
}

for _, tc := range testCases {
Expand Down Expand Up @@ -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)
})
}
}
6 changes: 5 additions & 1 deletion sdk/internal/zipstream/segment_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
Loading
Loading