Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ ARG UI_TAG
ARG UI_RELEASE
RUN apk add --update --no-cache \
sqlite=3.48.0-r4 \
postgresql16-client=16.11-r0 \
postgresql16-client=16.12-r0 \
curl=8.14.1-r2 \
jq=1.7.1-r0
WORKDIR /firefly
Expand Down
13 changes: 12 additions & 1 deletion internal/operations/operation_updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ package operations

import (
"context"
"encoding/hex"
"fmt"
"strings"
"time"
"unicode/utf8"

"github.com/hyperledger/firefly-common/pkg/config"
"github.com/hyperledger/firefly-common/pkg/ffapi"
Expand Down Expand Up @@ -422,7 +425,15 @@ func (ou *operationUpdater) resolveOperation(ctx context.Context, ns string, id
update = update.Set("status", status)
}
if errorMsg != nil {
update = update.Set("error", *errorMsg)
// PostgreSQL text columns reject null bytes and invalid UTF-8 sequences.
// Null bytes (0x00) are valid UTF-8 but rejected by PostgreSQL, so check both.
if !utf8.ValidString(*errorMsg) || strings.ContainsRune(*errorMsg, 0) {
hexString := hex.EncodeToString([]byte(*errorMsg))
log.L(ctx).Warnf("Error message contains invalid UTF-8 or null bytes - encoding as hex: %s", hexString)
update = update.Set("error", hexString)
} else {
update = update.Set("error", *errorMsg)
}
Comment thread
davecrighton marked this conversation as resolved.
Outdated
}
if output != nil {
update = update.Set("output", output)
Expand Down
113 changes: 113 additions & 0 deletions internal/operations/operation_updater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,119 @@ func TestDoUpdateVerifyBatchManifestFail(t *testing.T) {
mdi.AssertExpectations(t)
}

func TestResolveOperationValidUTF8ErrorPassesThrough(t *testing.T) {
ou := newTestOperationUpdaterNoConcurrency(t)
defer ou.close()

opID1 := fftypes.NewUUID()
mdi := ou.database.(*databasemocks.Plugin)
mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{
{ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke},
}, nil, nil)
mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{
{"status", "Failed"},
{"error", "FF23021: EVM reverted: some normal error message"},
}))).Return(true, nil)

ou.initQueues()

err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{
{NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: "FF23021: EVM reverted: some normal error message"},
})
assert.NoError(t, err)

mdi.AssertExpectations(t)
}

func TestResolveOperationInvalidUTF8ErrorHexEncoded(t *testing.T) {
ou := newTestOperationUpdaterNoConcurrency(t)
defer ou.close()

opID1 := fftypes.NewUUID()

// Simulate the actual revert scenario: readable text with embedded ABI-encoded Error(string)
// selector bytes (0x08, 0xc3, 0x79, 0xa0) and null byte padding, which is invalid UTF-8
invalidMsg := "[OCPE]404/98 - \x08\xc3\x79\xa0\x00\x00\x00[TMM]404/16e"
expectedHex := "5b4f4350455d3430342f3938202d2008c379a00000005b544d4d5d3430342f313665"

mdi := ou.database.(*databasemocks.Plugin)
mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{
{ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke},
}, nil, nil)
mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{
{"status", "Failed"},
{"error", expectedHex},
}))).Return(true, nil)

ou.initQueues()

err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{
{NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: invalidMsg},
})
assert.NoError(t, err)

mdi.AssertExpectations(t)
}

func TestResolveOperationNullBytesOnlyInvalidUTF8(t *testing.T) {
ou := newTestOperationUpdaterNoConcurrency(t)
defer ou.close()

opID1 := fftypes.NewUUID()

// Null bytes mixed with non-continuation bytes that break UTF-8 validity
invalidMsg := "error\x00with\x80null"
expectedHex := "6572726f720077697468806e756c6c"

mdi := ou.database.(*databasemocks.Plugin)
mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{
{ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke},
}, nil, nil)
mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{
{"status", "Failed"},
{"error", expectedHex},
}))).Return(true, nil)

ou.initQueues()

err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{
{NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: invalidMsg},
})
assert.NoError(t, err)

mdi.AssertExpectations(t)
}

func TestResolveOperationNullBytesInValidUTF8HexEncoded(t *testing.T) {
ou := newTestOperationUpdaterNoConcurrency(t)
defer ou.close()

opID1 := fftypes.NewUUID()

// Pure null bytes embedded in otherwise valid UTF-8 text.
// utf8.ValidString returns true for this, but PostgreSQL rejects 0x00 in text columns.
invalidMsg := "hello\x00world"
expectedHex := "68656c6c6f00776f726c64"

mdi := ou.database.(*databasemocks.Plugin)
mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{
{ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke},
}, nil, nil)
mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{
{"status", "Failed"},
{"error", expectedHex},
}))).Return(true, nil)

ou.initQueues()

err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{
{NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: invalidMsg},
})
assert.NoError(t, err)

mdi.AssertExpectations(t)
}

func TestDoUpdateVerifyBlobManifestFail(t *testing.T) {
ou := newTestOperationUpdaterNoConcurrency(t)
defer ou.close()
Expand Down