diff --git a/backend/internxt/internxt.go b/backend/internxt/internxt.go index a221abe323012..835604c86aee4 100644 --- a/backend/internxt/internxt.go +++ b/backend/internxt/internxt.go @@ -21,6 +21,7 @@ import ( "github.com/internxt/rclone-adapter/folders" "github.com/internxt/rclone-adapter/users" "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/chunksize" rclone_config "github.com/rclone/rclone/fs/config" "github.com/rclone/rclone/fs/config/configmap" "github.com/rclone/rclone/fs/config/configstruct" @@ -30,15 +31,19 @@ import ( "github.com/rclone/rclone/fs/hash" "github.com/rclone/rclone/lib/dircache" "github.com/rclone/rclone/lib/encoder" + "github.com/rclone/rclone/lib/multipart" "github.com/rclone/rclone/lib/oauthutil" "github.com/rclone/rclone/lib/pacer" "github.com/rclone/rclone/lib/random" ) const ( - minSleep = 10 * time.Millisecond - maxSleep = 2 * time.Second - decayConstant = 2 // bigger for slower decay, exponential + minSleep = 10 * time.Millisecond + maxSleep = 2 * time.Second + decayConstant = 2 + maxUploadParts = 10000 + minMultipartSize = 100 * 1024 * 1024 + minChunkSize = fs.SizeSuffix(5 * 1024 * 1024) ) // shouldRetry determines if an error should be retried. @@ -101,6 +106,16 @@ func init() { Default: true, Advanced: true, Help: "Skip hash validation when downloading files.\n\nBy default, hash validation is disabled. Set this to false to enable validation.", + }, { + Name: "chunk_size", + Help: "Chunk size for multipart uploads.\n\nLarge files will be uploaded in chunks of this size.\n\nMemory usage is approximately chunk_size * upload_concurrency.", + Default: fs.SizeSuffix(30 * 1024 * 1024), + Advanced: true, + }, { + Name: "upload_concurrency", + Help: "Concurrency for multipart uploads.\n\nThis is the number of chunks of the same file that are uploaded concurrently.\n\nNote that each chunk is buffered in memory.", + Default: 4, + Advanced: true, }, { Name: rclone_config.ConfigEncoding, Help: rclone_config.ConfigEncodingHelp, @@ -194,6 +209,8 @@ type Options struct { TwoFA string `config:"2fa"` Mnemonic string `config:"mnemonic"` SkipHashValidation bool `config:"skip_hash_validation"` + ChunkSize fs.SizeSuffix `config:"chunk_size"` + UploadConcurrency int `config:"upload_concurrency"` Encoding encoder.MultiEncoder `config:"encoding"` } @@ -255,6 +272,10 @@ func NewFs(ctx context.Context, name, root string, m configmap.Mapper) (fs.Fs, e return nil, err } + if err := checkUploadChunkSize(opt.ChunkSize); err != nil { + return nil, fmt.Errorf("internxt: chunk size: %w", err) + } + if opt.Mnemonic == "" { return nil, errors.New("mnemonic is required - please run: rclone config reconnect " + name + ":") } @@ -884,32 +905,63 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op fs.Debugf(o.f, "Renamed existing file %s to backup %s.%s (UUID: %s)", remote, backupName, backupType, backupUUID) } + size := src.Size() + var meta *buckets.CreateMetaResponse - err = o.f.pacer.CallNoRetry(func() (bool, error) { - var err error - meta, err = buckets.UploadFileStreamAuto(ctx, - o.f.cfg, - dirID, - o.f.opt.Encoding.FromStandardName(path.Base(remote)), - in, - src.Size(), - src.ModTime(ctx), - ) - return o.f.shouldRetry(ctx, err) - }) + if size >= minMultipartSize { + ci := fs.GetConfig(ctx) + chunkSize := chunksize.Calculator(src, size, maxUploadParts, o.f.opt.ChunkSize) + if ci.MaxBufferMemory > 0 { + perTransfer := int64(ci.BufferSize) + int64(chunkSize)*int64(o.f.opt.UploadConcurrency) + needed := perTransfer * int64(ci.Transfers) + if int64(ci.MaxBufferMemory) < needed { + return fmt.Errorf("--max-buffer-memory %v is too small for multipart upload: need at least %v (%d transfers * (--buffer-size %v + chunk_size %v * upload_concurrency %d)); increase --max-buffer-memory or reduce transfers/chunk_size/upload_concurrency/buffer-size", + ci.MaxBufferMemory, fs.SizeSuffix(needed), ci.Transfers, ci.BufferSize, chunkSize, o.f.opt.UploadConcurrency) + } + } + chunkWriter, uploadErr := multipart.UploadMultipart(ctx, src, in, multipart.UploadMultipartOptions{ + Open: o.f, + OpenOptions: options, + }) - if err != nil && isEmptyFileLimitError(err) { - o.restoreBackupFile(ctx, backupUUID, origName, origType) - return fs.ErrorCantUploadEmptyFiles - } + if uploadErr != nil { + if isEmptyFileLimitError(uploadErr) { + o.restoreBackupFile(ctx, backupUUID, origName, origType) + return fs.ErrorCantUploadEmptyFiles + } + o.restoreBackupFile(ctx, backupUUID, origName, origType) + return uploadErr + } + w := chunkWriter.(*internxtChunkWriter) + meta = w.meta + } else { + // Use single-part upload for small files + err = o.f.pacer.CallNoRetry(func() (bool, error) { + var err error + meta, err = buckets.UploadFileStreamAuto(ctx, + o.f.cfg, + dirID, + o.f.opt.Encoding.FromStandardName(path.Base(remote)), + in, + size, + src.ModTime(ctx), + ) + return o.f.shouldRetry(ctx, err) + }) - if err != nil { - meta, err = o.recoverFromTimeoutConflict(ctx, err, remote, dirID) - } + if err != nil && isEmptyFileLimitError(err) { + o.restoreBackupFile(ctx, backupUUID, origName, origType) + return fs.ErrorCantUploadEmptyFiles + } - if err != nil { - o.restoreBackupFile(ctx, backupUUID, origName, origType) - return err + if err != nil { + meta, err = o.recoverFromTimeoutConflict(ctx, err, remote, dirID) + } + + if err != nil { + o.restoreBackupFile(ctx, backupUUID, origName, origType) + return err + } } // Update object metadata diff --git a/backend/internxt/internxt_test.go b/backend/internxt/internxt_test.go index 01e23b3b20606..154314ac64582 100644 --- a/backend/internxt/internxt_test.go +++ b/backend/internxt/internxt_test.go @@ -3,6 +3,7 @@ package internxt_test import ( "testing" + "github.com/rclone/rclone/fs" "github.com/rclone/rclone/fstest/fstests" ) @@ -10,5 +11,9 @@ import ( func TestIntegration(t *testing.T) { fstests.Run(t, &fstests.Opt{ RemoteName: "TestInternxt:", + ChunkedUpload: fstests.ChunkedUploadConfig{ + MinChunkSize: 100 * fs.Mebi, + NeedMultipleChunks: true, + }, }) } diff --git a/backend/internxt/upload.go b/backend/internxt/upload.go new file mode 100644 index 0000000000000..407304574cbb7 --- /dev/null +++ b/backend/internxt/upload.go @@ -0,0 +1,241 @@ +package internxt + +import ( + "bytes" + "context" + "fmt" + "io" + "path" + "sort" + "strings" + "sync" + + "github.com/internxt/rclone-adapter/buckets" + "github.com/rclone/rclone/fs" + "github.com/rclone/rclone/fs/chunksize" +) + +var warnStreamUpload sync.Once + +func checkUploadChunkSize(cs fs.SizeSuffix) error { + if cs < minChunkSize { + return fmt.Errorf("%s is less than %s", cs, minChunkSize) + } + return nil +} + +// SetUploadChunkSize sets the chunk size used for multipart uploads +func (f *Fs) SetUploadChunkSize(cs fs.SizeSuffix) (fs.SizeSuffix, error) { + err := checkUploadChunkSize(cs) + if err == nil { + old := f.opt.ChunkSize + f.opt.ChunkSize = cs + return old, nil + } + return f.opt.ChunkSize, err +} + +// internxtChunkWriter implements fs.ChunkWriter for Internxt multipart uploads. +type internxtChunkWriter struct { + f *Fs + remote string + src fs.ObjectInfo + session *buckets.ChunkUploadSession + completedParts []buckets.CompletedPart + partsMu sync.Mutex + size int64 + dirID string + meta *buckets.CreateMetaResponse + chunkSize int64 + hashMu sync.Mutex + nextHashChunk int + pendingChunks map[int][]byte +} + +// OpenChunkWriter returns the chunk size and a ChunkWriter for multipart uploads. +func (f *Fs) OpenChunkWriter(ctx context.Context, remote string, src fs.ObjectInfo, options ...fs.OpenOption) (info fs.ChunkWriterInfo, writer fs.ChunkWriter, err error) { + size := src.Size() + + info = fs.ChunkWriterInfo{ + ChunkSize: int64(f.opt.ChunkSize), + Concurrency: f.opt.UploadConcurrency, + LeavePartsOnError: false, + MinFileSize: minMultipartSize, + } + + // Reject files below the multipart minimum + if size >= 0 && size < minMultipartSize { + return info, nil, fmt.Errorf("file size %d is below minimum %d for multipart upload", size, minMultipartSize) + } + + chunkSize := f.opt.ChunkSize + if size < 0 { + warnStreamUpload.Do(func() { + fs.Logf(f, "Streaming uploads using chunk size %v will have maximum file size of %v", + chunkSize, fs.SizeSuffix(int64(chunkSize)*int64(maxUploadParts))) + }) + } else { + chunkSize = chunksize.Calculator(src, size, maxUploadParts, chunkSize) + info.ChunkSize = int64(chunkSize) + } + + // Ensure parent directory exists + _, dirID, err := f.dirCache.FindPath(ctx, remote, true) + if err != nil { + return info, nil, fmt.Errorf("failed to find parent directory: %w", err) + } + + var session *buckets.ChunkUploadSession + err = f.pacer.Call(func() (bool, error) { + var err error + session, err = buckets.NewChunkUploadSession(ctx, f.cfg, size, int64(chunkSize)) + return f.shouldRetry(ctx, err) + }) + if err != nil { + return info, nil, fmt.Errorf("failed to create upload session: %w", err) + } + + w := &internxtChunkWriter{ + f: f, + remote: remote, + src: src, + session: session, + size: size, + dirID: dirID, + chunkSize: int64(chunkSize), + pendingChunks: make(map[int][]byte), + } + + return info, w, nil +} + +// WriteChunk encrypts plaintext per-chunk using AES-256-CTR at the correct +// byte offset, feeds encrypted data into the ordered hash accumulator, and +// uploads to the presigned URL. +func (w *internxtChunkWriter) WriteChunk(ctx context.Context, chunkNumber int, reader io.ReadSeeker) (int64, error) { + plaintext, err := io.ReadAll(reader) + if err != nil { + return 0, err + } + if len(plaintext) == 0 { + return 0, nil + } + size := int64(len(plaintext)) + + byteOffset := int64(chunkNumber) * w.chunkSize + cipherStream, err := w.session.NewCipherAtOffset(byteOffset) + if err != nil { + return 0, err + } + cipherStream.XORKeyStream(plaintext, plaintext) + encrypted := plaintext + + w.submitForHashing(chunkNumber, encrypted) + + encReader := bytes.NewReader(encrypted) + var etag string + err = w.f.pacer.Call(func() (bool, error) { + if _, err := encReader.Seek(0, io.SeekStart); err != nil { + return false, err + } + var uploadErr error + etag, uploadErr = w.session.UploadChunk(ctx, chunkNumber, encReader, size) + return w.f.shouldRetry(ctx, uploadErr) + }) + if err != nil { + return 0, err + } + + w.recordCompletedPart(chunkNumber, etag) + return size, nil +} + +// recordCompletedPart appends a completed part to the list (thread-safe). +func (w *internxtChunkWriter) recordCompletedPart(chunkNumber int, etag string) { + w.partsMu.Lock() + w.completedParts = append(w.completedParts, buckets.CompletedPart{ + PartNumber: chunkNumber + 1, + ETag: etag, + }) + w.partsMu.Unlock() +} + +// submitForHashing feeds encrypted chunk data into the session's hash in order. +func (w *internxtChunkWriter) submitForHashing(chunkNumber int, encrypted []byte) { + w.hashMu.Lock() + defer w.hashMu.Unlock() + + if chunkNumber == w.nextHashChunk { + w.session.HashEncryptedData(encrypted) + w.nextHashChunk++ + for { + next, ok := w.pendingChunks[w.nextHashChunk] + if !ok { + break + } + w.session.HashEncryptedData(next) + delete(w.pendingChunks, w.nextHashChunk) + w.nextHashChunk++ + } + } else { + buf := make([]byte, len(encrypted)) + copy(buf, encrypted) + w.pendingChunks[chunkNumber] = buf + } +} + +// Close completes the multipart upload and registers the file in Internxt Drive. +func (w *internxtChunkWriter) Close(ctx context.Context) error { + w.hashMu.Lock() + pending := len(w.pendingChunks) + w.hashMu.Unlock() + if pending != 0 { + return fmt.Errorf("internal error: %d chunks still pending hash", pending) + } + + // Sort parts by part number + w.partsMu.Lock() + sort.Slice(w.completedParts, func(i, j int) bool { + return w.completedParts[i].PartNumber < w.completedParts[j].PartNumber + }) + parts := make([]buckets.CompletedPart, len(w.completedParts)) + copy(parts, w.completedParts) + w.partsMu.Unlock() + + // Finish multipart upload (SDK computes hash + calls FinishMultipartUpload) + var finishResp *buckets.FinishUploadResp + err := w.f.pacer.Call(func() (bool, error) { + var err error + finishResp, err = w.session.Finish(ctx, parts) + return w.f.shouldRetry(ctx, err) + }) + if err != nil { + return fmt.Errorf("failed to finish multipart upload: %w", err) + } + + // Create file metadata in Internxt Drive + baseName := w.f.opt.Encoding.FromStandardName(path.Base(w.remote)) + name := strings.TrimSuffix(baseName, path.Ext(baseName)) + ext := strings.TrimPrefix(path.Ext(baseName), ".") + + var meta *buckets.CreateMetaResponse + err = w.f.pacer.Call(func() (bool, error) { + var err error + meta, err = buckets.CreateMetaFile(ctx, w.f.cfg, + name, w.f.cfg.Bucket, &finishResp.ID, "03-aes", + w.dirID, name, ext, w.size, w.src.ModTime(ctx)) + return w.f.shouldRetry(ctx, err) + }) + if err != nil { + return fmt.Errorf("failed to create file metadata: %w", err) + } + w.meta = meta + + return nil +} + +// Abort cleans up after a failed upload. +func (w *internxtChunkWriter) Abort(ctx context.Context) error { + fs.Logf(w.f, "Multipart upload aborted for %s", w.remote) + return nil +} diff --git a/fs/features.go b/fs/features.go index 1563dc459cd7a..a5df778cb6f17 100644 --- a/fs/features.go +++ b/fs/features.go @@ -739,6 +739,7 @@ type ChunkWriterInfo struct { ChunkSize int64 // preferred chunk size Concurrency int // how many chunks to write at once LeavePartsOnError bool // if set don't delete parts uploaded so far on error + MinFileSize int64 // minimum file size for multipart uploads, 0 means no minimum } // OpenChunkWriter is an option interface for Fs to implement chunked writing diff --git a/fs/operations/multithread_test.go b/fs/operations/multithread_test.go index d3a07ae7153ca..492d80cd7629c 100644 --- a/fs/operations/multithread_test.go +++ b/fs/operations/multithread_test.go @@ -112,8 +112,8 @@ func TestMultithreadCalculateNumChunks(t *testing.T) { } } -// Skip if not multithread, returning the chunkSize otherwise -func skipIfNotMultithread(ctx context.Context, t *testing.T, r *fstest.Run) int { +// Skip if not multithread, returning the chunkSize and minFileSize otherwise +func skipIfNotMultithread(ctx context.Context, t *testing.T, r *fstest.Run) (chunkSize int, minFileSize int64) { features := r.Fremote.Features() if features.OpenChunkWriter == nil && features.OpenWriterAt == nil { t.Skip("multithread writing not supported") @@ -128,7 +128,7 @@ func skipIfNotMultithread(ctx context.Context, t *testing.T, r *fstest.Run) int } ci := fs.GetConfig(ctx) - chunkSize := int(ci.MultiThreadChunkSize) + chunkSize = int(ci.MultiThreadChunkSize) if features.OpenChunkWriter != nil { //OpenChunkWriter func(ctx context.Context, remote string, src ObjectInfo, options ...OpenOption) (info ChunkWriterInfo, writer ChunkWriter, err error) const fileName = "chunksize-probe" @@ -136,16 +136,17 @@ func skipIfNotMultithread(ctx context.Context, t *testing.T, r *fstest.Run) int info, writer, err := features.OpenChunkWriter(ctx, fileName, src) require.NoError(t, err) chunkSize = int(info.ChunkSize) + minFileSize = info.MinFileSize err = writer.Abort(ctx) require.NoError(t, err) } - return chunkSize + return chunkSize, minFileSize } func TestMultithreadCopy(t *testing.T) { r := fstest.NewRun(t) ctx := context.Background() - chunkSize := skipIfNotMultithread(ctx, t, r) + chunkSize, minFileSize := skipIfNotMultithread(ctx, t, r) // Check every other transfer for metadata checkMetadata := false ctx, ci := fs.AddConfig(ctx) @@ -163,6 +164,9 @@ func TestMultithreadCopy(t *testing.T) { ci.Metadata = checkMetadata fileName := fmt.Sprintf("test-multithread-copy-%v-%d-%d", upload, test.size, test.streams) t.Run(fmt.Sprintf("upload=%v,size=%v,streams=%v", upload, test.size, test.streams), func(t *testing.T) { + if minFileSize > 0 && int64(test.size) < minFileSize { + t.Skipf("file size %d is below backend minimum %d for multipart uploads", test.size, minFileSize) + } if *fstest.SizeLimit > 0 && int64(test.size) > *fstest.SizeLimit { t.Skipf("exceeded file size limit %d > %d", test.size, *fstest.SizeLimit) } @@ -290,9 +294,12 @@ func (rc wgReadCloser) Close() (err error) { func TestMultithreadCopyAbort(t *testing.T) { r := fstest.NewRun(t) ctx := context.Background() - chunkSize := skipIfNotMultithread(ctx, t, r) + chunkSize, minFileSize := skipIfNotMultithread(ctx, t, r) size := 2*chunkSize + 1 + if minFileSize > 0 && int64(size) < minFileSize { + t.Skipf("file size %d is below backend minimum %d for multipart uploads", size, minFileSize) + } if *fstest.SizeLimit > 0 && int64(size) > *fstest.SizeLimit { t.Skipf("exceeded file size limit %d > %d", size, *fstest.SizeLimit) } diff --git a/fstest/fstests/fstests.go b/fstest/fstests/fstests.go index 4db6f29070bba..32bbc6d1472a5 100644 --- a/fstest/fstests/fstests.go +++ b/fstest/fstests/fstests.go @@ -820,19 +820,23 @@ func Run(t *testing.T, opt *Opt) { t.Skip("FS has no OpenChunkWriter interface") } size5MBs := 5 * 1024 * 1024 - contents1 := random.String(size5MBs) - contents2 := random.String(size5MBs) - size1MB := 1 * 1024 * 1024 - contents3 := random.String(size1MB) + totalSize := int64(size5MBs*2 + size1MB) path := "writer-at-subdir/writer-at-file" - objSrc := object.NewStaticObjectInfo(path+"-WRONG-REMOTE", file1.ModTime, -1, true, nil, nil) - _, out, err := openChunkWriter(ctx, path, objSrc, &fs.ChunkOption{ + objSrc := object.NewStaticObjectInfo(path+"-WRONG-REMOTE", file1.ModTime, totalSize, true, nil, nil) + info, out, err := openChunkWriter(ctx, path, objSrc, &fs.ChunkOption{ ChunkSize: int64(size5MBs), }) + if info.MinFileSize > 0 && totalSize < info.MinFileSize { + t.Skipf("file size %d is below backend minimum %d for multipart uploads", totalSize, info.MinFileSize) + } require.NoError(t, err) + contents1 := random.String(size5MBs) + contents2 := random.String(size5MBs) + contents3 := random.String(size1MB) + var n int64 n, err = out.WriteChunk(ctx, 1, strings.NewReader(contents2)) assert.NoError(t, err) diff --git a/go.mod b/go.mod index ca4a5c909fce0..207e7d8082724 100644 --- a/go.mod +++ b/go.mod @@ -45,7 +45,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 github.com/hanwen/go-fuse/v2 v2.9.0 - github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd + github.com/internxt/rclone-adapter v0.0.0-20260316170255-0cc0b8f65dee github.com/jcmturner/gokrb5/v8 v8.4.4 github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 github.com/josephspurrier/goversioninfo v1.5.0 @@ -96,9 +96,9 @@ require ( golang.org/x/crypto v0.48.0 golang.org/x/net v0.51.0 golang.org/x/oauth2 v0.35.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 golang.org/x/sys v0.41.0 - golang.org/x/text v0.34.0 + golang.org/x/text v0.35.0 golang.org/x/time v0.14.0 google.golang.org/api v0.267.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 @@ -260,7 +260,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/image v0.36.0 // indirect + golang.org/x/image v0.38.0 // indirect golang.org/x/tools v0.42.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/grpc v1.79.3 // indirect diff --git a/go.sum b/go.sum index c033e80a9cf43..20b17625223e3 100644 --- a/go.sum +++ b/go.sum @@ -423,8 +423,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd h1:dSIuz2mpJAPQfhHYtG57D0qwSkgC/vQ69gHfeyQ4kxA= -github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0= +github.com/internxt/rclone-adapter v0.0.0-20260316170255-0cc0b8f65dee h1:Crt8J2oP3i6x5z6wkdS8jKTZ7lLhE3nhLNnq676rBvg= +github.com/internxt/rclone-adapter v0.0.0-20260316170255-0cc0b8f65dee/go.mod h1:vdPya4AIcDjvng4ViaAzqjegJf0VHYpYHQguFx5xBp0= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -786,8 +786,8 @@ golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05 golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= -golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -880,8 +880,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -961,8 +961,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=