From 8babdbeb126a142c60c8136ebd9a0d367144afca Mon Sep 17 00:00:00 2001 From: llogen Date: Fri, 24 Jul 2026 12:33:09 +0200 Subject: [PATCH] feat: chunked file transfer protocol Files move between client and agent in bounded, individually acknowledged chunks, so large transfers stream reliably within the run. Signed-off-by: llogen --- .github/workflows/go.yml | 44 ++ cmds/dutagent/states.go | 10 + cmds/dutagent/states_test.go | 62 ++ cmds/dutctl/file_transfer.go | 678 +++++++++++++++++ cmds/dutctl/file_transfer_test.go | 597 +++++++++++++++ cmds/dutctl/rpc.go | 75 +- cmds/exp/dutserver/rpc.go | 18 +- go.mod | 2 +- internal/dutagent/session/broker.go | 44 +- internal/dutagent/session/broker_test.go | 43 +- .../dutagent/session/filetransfer_test.go | 682 ++++++++++++++++++ .../dutagent/session/sendserialize_test.go | 75 ++ internal/dutagent/session/session.go | 639 +++++++++++++--- internal/dutagent/session/worker.go | 562 ++++++++++++--- internal/test/mock/session.go | 5 +- pkg/module/dummy/dummy_file_transfer.go | 4 +- pkg/module/file/file.go | 12 +- pkg/module/flash-emulate/flash-emulate.go | 6 +- pkg/module/flash/flash.go | 25 +- pkg/module/module.go | 24 +- protobuf/buf.yaml | 2 +- protobuf/dutctl/v1/dutctl.proto | 71 +- protobuf/gen/dutctl/v1/dutctl.pb.go | 646 +++++++++++++---- 23 files changed, 3885 insertions(+), 441 deletions(-) create mode 100644 cmds/dutctl/file_transfer.go create mode 100644 cmds/dutctl/file_transfer_test.go create mode 100644 internal/dutagent/session/filetransfer_test.go create mode 100644 internal/dutagent/session/sendserialize_test.go diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1be8a94c..6bb7b7b6 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -79,6 +79,50 @@ jobs: with: version: v2.6.1 + # Protobuf checks run once per workflow execution, not per commit. + # + # buf breaking is deliberately not run: the wire format is still changing while + # the project is pre-1.0, and agent and client are released together. Add it + # once the protocol is stable. + protobuf: + name: Protobuf + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: 'go.mod' + + - uses: bufbuild/buf-setup-action@v1 + with: + version: 1.47.2 + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Lint + working-directory: protobuf + run: buf lint + + - name: Format + working-directory: protobuf + run: buf format --diff --exit-code + + # The generated Go code is committed, so it has to match the .proto files. + # Plugin versions come from go.mod rather than being pinned again here, so + # a dependency bump cannot silently desynchronise the two. + - name: Install codegen plugins + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@"$(go list -m -f '{{.Version}}' google.golang.org/protobuf)" + go install connectrpc.com/connect/cmd/protoc-gen-connect-go@"$(go list -m -f '{{.Version}}' connectrpc.com/connect)" + + - name: Generated code is up to date + working-directory: protobuf + run: | + buf generate + git diff --exit-code -- gen \ + || { echo "::error::protobuf/gen is stale - run 'buf generate' in protobuf/ and commit the result"; exit 1; } + # Build and test each commit for both architectures # Uses a matrix strategy to create separate job runs for each combination of commit and architecture per_commit_build_test: diff --git a/cmds/dutagent/states.go b/cmds/dutagent/states.go index bebb629e..d86310af 100644 --- a/cmds/dutagent/states.go +++ b/cmds/dutagent/states.go @@ -249,6 +249,16 @@ func executeModules(ctx context.Context, args runCmdArgs) (runCmdArgs, fsm.State } l.Info("all modules finished successfully") + + // A module may start a file transfer that completes asynchronously (a + // download handed to SendFile is streamed by the broker workers after the + // module's Run returns). Signal graceful shutdown — which stops forwarding + // further module output but keeps the transfer flowing — and wait for every + // in-flight transfer to finish before cancelling the workers. abortTransfers + // is the backstop if the workers exit first (e.g. the client disconnects). + broker.Shutdown() + broker.WaitForTransfersToComplete() + modCtxCancel() close(args.moduleErrCh) }() diff --git a/cmds/dutagent/states_test.go b/cmds/dutagent/states_test.go index 7526945b..96706a69 100644 --- a/cmds/dutagent/states_test.go +++ b/cmds/dutagent/states_test.go @@ -700,3 +700,65 @@ func TestWaitModules(t *testing.T) { }) } } + +// ctxCapturingModule records the context Run was handed. +type ctxCapturingModule struct { + ctx context.Context //nolint:containedctx // captured for assertion, never used to call +} + +func (m *ctxCapturingModule) Help() string { return "capture" } +func (m *ctxCapturingModule) Init(_ context.Context) error { return nil } +func (m *ctxCapturingModule) Deinit(_ context.Context) error { return nil } + +func (m *ctxCapturingModule) Run(ctx context.Context, _ module.Session, _ ...string) error { + m.ctx = ctx + + return nil +} + +// TestModuleContextOutlivesBrokerCancel pins where the module's context comes +// from. File transfers are bounded by it and outlive Run, so it must derive from +// the RPC context, not the broker's. Deriving it from modCtx looks like a +// tidy-up and passes every other test: downloads would be truncated at +// modCtxCancel and the run would still report success. +func TestModuleContextOutlivesBrokerCancel(t *testing.T) { + mod := &ctxCapturingModule{} + + wrap := dut.Module{} + wrap.Config.Name = "captureMod" + wrap.Config.Passthrough = true + wrap.Module = mod + + moduleErrCh := make(chan error, 1) + + args := runCmdArgs{ + stream: &fakes.FakeStream{}, + cmdMsg: &pb.Command{Device: "devX", Command: "cmdY"}, + cmd: dut.Command{Modules: []dut.Module{wrap}}, + moduleErrCh: moduleErrCh, + } + + _, _, err := executeModules(context.Background(), args) + if err != nil { + t.Fatalf("executeModules: %v", err) + } + + // Closed channel means the run goroutine finished, so modCtxCancel has run. + select { + case _, ok := <-moduleErrCh: + if ok { + t.Fatal("module reported an error") + } + case <-time.After(5 * time.Second): + t.Fatal("module execution did not finish") + } + + if mod.ctx == nil { + t.Fatal("module was never run") + } + + if err := mod.ctx.Err(); err != nil { + t.Errorf("module context cancelled by broker teardown: %v — "+ + "a transfer still streaming after Run returned would be aborted", err) + } +} diff --git a/cmds/dutctl/file_transfer.go b/cmds/dutctl/file_transfer.go new file mode 100644 index 00000000..37ec9519 --- /dev/null +++ b/cmds/dutctl/file_transfer.go @@ -0,0 +1,678 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "errors" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "sync" + + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" +) + +const ( + clientChunkSize = 1024 * 1024 // 1MB chunks + downloadFilePerms = 0o600 // Downloaded file permissions (user read/write only) +) + +// chunkStream is the send side of the run stream the file-transfer manager needs. +// sendStream satisfies it, and a test can substitute a recording fake. +type chunkStream interface { + Send(req *pb.RunRequest) error +} + +// sendStream serializes all sends on the run stream. Connect permits Send and +// Receive from separate goroutines but forbids concurrent Send calls, and the +// client sends from three: the receive loop (transfer acknowledgments), the +// stdin loop, and the goroutine sendUploadInChunks spawns. Receive is not +// wrapped — it stays on the raw stream, which is what makes the split legal. +type sendStream struct { + mu sync.Mutex + stream chunkStream +} + +func newSendStream(stream chunkStream) *sendStream { + return &sendStream{stream: stream} +} + +func (s *sendStream) Send(req *pb.RunRequest) error { + s.mu.Lock() + defer s.mu.Unlock() + + return s.stream.Send(req) +} + +// clientFileTransferState represents an active file transfer on the client. +type clientFileTransferState struct { + transferID string + path string + file *os.File + direction string // "upload" or "download" + expectedChunkNum int32 // For validating chunk sequence on download + mu sync.Mutex + + // ackCh carries the agent's per-chunk acknowledgment to the upload sender. + // Buffered to one: the protocol keeps a single chunk outstanding, so the + // sender is always the one waiting. + ackCh chan int32 + + // abort is closed when the transfer is dropped, so a sender waiting on an + // acknowledgment that will never arrive stops instead of leaking. + abort chan struct{} + abortOnce sync.Once +} + +// stop releases anyone waiting on this transfer. Safe to call repeatedly. +func (s *clientFileTransferState) stop() { + s.abortOnce.Do(func() { close(s.abort) }) +} + +// clientFileTransferManager manages file transfers on the client side. +type clientFileTransferManager struct { + transfers map[string]*clientFileTransferState + mu sync.RWMutex + + // allowed holds the command arguments normalised once at construction. Every + // chunk is checked against it, so normalising per lookup would repeat + // filepath.Abs for each argument on every megabyte transferred. + allowed map[string]struct{} +} + +func newClientFileTransferManager(cmdArgs []string) *clientFileTransferManager { + allowed := make(map[string]struct{}, len(cmdArgs)) + for _, arg := range cmdArgs { + allowed[normalizePath(arg)] = struct{}{} + } + + return &clientFileTransferManager{ + transfers: make(map[string]*clientFileTransferState), + allowed: allowed, + } +} + +func (m *clientFileTransferManager) registerTransfer(transferID, path, direction string) *clientFileTransferState { + m.mu.Lock() + defer m.mu.Unlock() + + state := &clientFileTransferState{ + transferID: transferID, + path: path, + direction: direction, + ackCh: make(chan int32, 1), + abort: make(chan struct{}), + } + + m.transfers[transferID] = state + + return state +} + +func (m *clientFileTransferManager) getTransfer(transferID string) *clientFileTransferState { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.transfers[transferID] +} + +func (m *clientFileTransferManager) removeTransfer(transferID string) { + m.mu.Lock() + defer m.mu.Unlock() + + if state, exists := m.transfers[transferID]; exists { + state.stop() + + if state.file != nil { + state.file.Close() + } + + delete(m.transfers, transferID) + } +} + +// normalizePath expands ~ and converts to absolute path for consistent comparison. +// Returns the normalized path or logs error and returns original path. +func normalizePath(path string) string { + // Expand ~ to home directory + expanded := path + if path != "" && path[0] == '~' { + home, err := os.UserHomeDir() + if err != nil { + slog.Warn("could not expand ~, using path as-is", "err", err) + + return path + } + + expanded = filepath.Join(home, path[1:]) + } + + // Convert to absolute path + abs, err := filepath.Abs(expanded) + if err != nil { + slog.Warn("could not convert to absolute path, using expanded path", "path", expanded, "err", err) + + return expanded + } + + return abs +} + +// isValidPath reports whether path is one the user named on the command line. +// The candidate is normalised (~ expanded, made absolute) to match the +// allow-list, which was normalised the same way at construction. +func (m *clientFileTransferManager) isValidPath(path string) bool { + _, ok := m.allowed[normalizePath(path)] + + return ok +} + +// sendChunkToAgent sends a file chunk to the agent. +func (m *clientFileTransferManager) sendChunkToAgent( + transferID string, + chunkNum int32, + data []byte, + isFinal bool, + stream chunkStream, +) error { + chunk := &pb.RunRequest{ + Msg: &pb.RunRequest_FileChunk{ + FileChunk: &pb.FileChunk{ + TransferId: transferID, + ChunkNumber: chunkNum, + ChunkData: data, + ChunkOffset: int64(chunkNum) * int64(clientChunkSize), + IsFinal: isFinal, + }, + }, + } + + return stream.Send(chunk) +} + +// handleUploadRequest processes a request to upload a file to the agent. +func (m *clientFileTransferManager) handleUploadRequest(transferID, path string, stream chunkStream) error { + // Validate that the requested file is in the command arguments + if !m.isValidPath(path) { + errMsg := fmt.Sprintf("file %q not specified in command arguments - security violation prevented", path) + slog.Warn(errMsg) + + rejectErr := m.sendTransferError(transferID, errMsg, stream) + if rejectErr != nil { + return fmt.Errorf("sending transfer rejection: %w", rejectErr) + } + + return nil + } + + info, statErr := os.Stat(path) + if statErr != nil { + slog.Warn("error accessing file", "path", path, "err", statErr) + + rejectErr := m.sendTransferError(transferID, fmt.Sprintf("cannot access file: %v", statErr), stream) + if rejectErr != nil { + return fmt.Errorf("sending transfer rejection: %w", rejectErr) + } + + return nil + } + + file, err := os.Open(path) + if err != nil { + slog.Warn("error opening file", "path", path, "err", err) + + rejectErr := m.sendTransferError(transferID, fmt.Sprintf("cannot open file: %v", err), stream) + if rejectErr != nil { + return fmt.Errorf("sending transfer rejection: %w", rejectErr) + } + + return nil + } + + state := m.registerTransfer(transferID, path, "upload") + state.file = file + + // Answer with the file's real metadata rather than a bare acceptance: the + // agent opened this transfer knowing only the path it asked for, so the size + // is the client's to report. + acceptErr := m.sendUploadMetadata(transferID, path, info.Size(), stream) + if acceptErr != nil { + file.Close() + m.removeTransfer(transferID) + + return fmt.Errorf("announcing upload: %w", acceptErr) + } + + slog.Debug("uploading file to device", "file", filepath.Base(path)) + + m.sendUploadInChunks(transferID, path, file, state, stream) + + return nil +} + +// handleDownloadRequest processes a request to download a file from the agent. +// The agent specifies what file it will send, and the destination path from +// command arguments is where we should save it. +func (m *clientFileTransferManager) handleDownloadRequest(transferID, destinationPath string, stream chunkStream) error { + // Validate that the destination file path is in the command arguments + if !m.isValidPath(destinationPath) { + errMsg := fmt.Sprintf("file %q not specified in command arguments - security violation prevented", destinationPath) + slog.Warn(errMsg) + + rejectErr := m.sendTransferError(transferID, errMsg, stream) + if rejectErr != nil { + return fmt.Errorf("sending transfer rejection: %w", rejectErr) + } + + return nil + } + + // Register the download transfer + m.registerTransfer(transferID, destinationPath, "download") + + slog.Debug("downloading file", "file", filepath.Base(destinationPath)) + + // Send acceptance to agent + acceptErr := m.sendTransferAcceptance(transferID, stream) + if acceptErr != nil { + m.removeTransfer(transferID) + + return fmt.Errorf("sending transfer acceptance: %w", acceptErr) + } + + return nil +} + +// sendUploadMetadata accepts an upload request by echoing it back with the +// file's measured size, which the agent records against the transfer. +func (m *clientFileTransferManager) sendUploadMetadata( + transferID, path string, + size int64, + stream chunkStream, +) error { + res := &pb.RunRequest{ + Msg: &pb.RunRequest_FileTransferRequest{ + FileTransferRequest: &pb.FileTransferRequest{ + TransferId: transferID, + Direction: pb.FileTransferRequest_DIRECTION_UPLOAD, + Metadata: &pb.FileMetadata{ + Path: path, + Name: filepath.Base(path), + Size: size, + }, + }, + }, + } + + return stream.Send(res) +} + +// sendTransferAcceptance sends a transfer acceptance response. +func (m *clientFileTransferManager) sendTransferAcceptance(transferID string, stream chunkStream) error { + res := &pb.RunRequest{ + Msg: &pb.RunRequest_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_ACCEPTED, + }, + }, + } + + return stream.Send(res) +} + +// sendUploadInChunks reads and sends a file in chunks to the agent, keeping one +// chunk outstanding at a time: each chunk waits for the agent's CHUNK_RECEIVED +// before the next is read and sent. +// +// The agent acknowledges a chunk only once the module has consumed it, so this +// is genuine end-to-end backpressure — the client cannot outrun the module, and +// the client's memory stays bounded at one chunk regardless of file size. The +// cost is one round trip per megabyte, which is far below the rate at which a +// module writes a chunk to hardware. +func (m *clientFileTransferManager) sendUploadInChunks( + transferID, path string, + file *os.File, + state *clientFileTransferState, + stream chunkStream, +) { + go func() { + defer file.Close() + defer m.removeTransfer(transferID) + + chunkNum := int32(0) + + for { + chunkData := make([]byte, clientChunkSize) + bytesRead, readErr := file.Read(chunkData) + + isFinal := errors.Is(readErr, io.EOF) + if readErr != nil && !isFinal { + slog.Warn("error reading file", "path", path, "err", readErr) + + return + } + + if bytesRead > 0 { + chunkData = chunkData[:bytesRead] + } else { + // Final empty chunk to signal EOF + chunkData = []byte{} + } + + chunkErr := m.sendChunkToAgent(transferID, chunkNum, chunkData, isFinal, stream) + if chunkErr != nil { + slog.Warn("error sending file chunk", "err", chunkErr) + + return + } + + chunkNum++ + + // The final chunk is answered with TRANSFER_COMPLETE, not an ack. + if isFinal { + return + } + + if !waitForChunkAck(state, chunkNum) { + return + } + } + }() +} + +// waitForChunkAck blocks until the agent acknowledges the chunk before wantNext, +// or the transfer is dropped. It reports whether sending should continue. +// +// The transfer's abort channel is the only exit besides the acknowledgment: it +// closes when the transfer is removed, which covers a rejection, a stream error +// and the end of the run. Without it a sender would wait forever for an +// acknowledgment the agent can no longer send. +func waitForChunkAck(state *clientFileTransferState, wantNext int32) bool { + for { + select { + case <-state.abort: + slog.Debug("upload stopped while awaiting acknowledgment", + "transfer_id", state.transferID, "chunk", wantNext-1) + + return false + case next := <-state.ackCh: + // Ignore a stale acknowledgment for a chunk already past; only the one + // for the chunk in flight releases the sender. + if next >= wantNext { + return true + } + } + } +} + +// handleFileTransferRequest handles a FileTransferRequest from the agent. +// This can be either: +// 1. A request for the client to upload a file to the agent (agent requesting from client) +// 2. A notification that the agent will send a file download +// The direction is explicitly specified in the FileTransferRequest message. +func (m *clientFileTransferManager) handleFileTransferRequest(ftReq *pb.FileTransferRequest, stream chunkStream) error { + transferID := ftReq.GetTransferId() + metadata := ftReq.GetMetadata() + path := metadata.GetPath() + direction := ftReq.GetDirection() + + switch direction { + case pb.FileTransferRequest_DIRECTION_UPLOAD: + // Agent is requesting a file from the client (client uploads to agent) + return m.handleUploadRequest(transferID, path, stream) + + case pb.FileTransferRequest_DIRECTION_DOWNLOAD: + // Agent is sending a file to the client (client downloads from agent) + return m.handleDownloadRequest(transferID, path, stream) + + default: + // Unspecified or unknown direction + errMsg := fmt.Sprintf("unknown transfer direction %v (agent/client version mismatch?)", direction) + slog.Warn(errMsg) + + return m.sendTransferError(transferID, errMsg, stream) + } +} + +// sendTransferError sends an error response for a failed transfer. +func (m *clientFileTransferManager) sendTransferError(transferID, message string, stream chunkStream) error { + res := &pb.RunRequest{ + Msg: &pb.RunRequest_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_TRANSFER_REJECTED, + ErrorMessage: message, + }, + }, + } + + return stream.Send(res) +} + +// sendChunkAcknowledgment sends an acknowledgment for a received chunk. +func (m *clientFileTransferManager) sendChunkAcknowledgment(transferID string, nextChunk int32, stream chunkStream) error { + res := &pb.RunRequest{ + Msg: &pb.RunRequest_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_CHUNK_RECEIVED, + NextChunkExpected: nextChunk, + }, + }, + } + + return stream.Send(res) +} + +// sendTransferComplete sends a transfer completion response. +func (m *clientFileTransferManager) sendTransferComplete(transferID string, stream chunkStream) error { + res := &pb.RunRequest{ + Msg: &pb.RunRequest_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE, + }, + }, + } + + return stream.Send(res) +} + +// validateChunkTransfer validates transfer existence, path authorization, and chunk sequence. +// Returns error if validation fails (error already sent to stream), nil otherwise. +func (m *clientFileTransferManager) validateChunkTransfer( + chunk *pb.FileChunk, + stream chunkStream, +) (*clientFileTransferState, error) { + transferID := chunk.GetTransferId() + + // Validate transfer exists. + state := m.getTransfer(transferID) + if state == nil { + slog.Warn("received chunk for unknown transfer", "transfer_id", transferID) + + sendErr := m.sendTransferError(transferID, "unknown transfer", stream) + if sendErr != nil { + return nil, fmt.Errorf("sending error response: %w", sendErr) + } + + return nil, fmt.Errorf("unknown transfer") + } + + // Re-check the path on every chunk. It is redundant with the check made when + // the transfer was registered, and deliberately so: this is the control that + // stops the agent writing outside the paths the user named, and it is cheap + // now that the allow-list is normalised once (see newClientFileTransferManager). + if !m.isValidPath(state.path) { + errMsg := fmt.Sprintf("file %q not in command arguments", state.path) + slog.Warn(errMsg) + + m.removeTransfer(transferID) + + sendErr := m.sendTransferError(transferID, errMsg, stream) + if sendErr != nil { + return nil, fmt.Errorf("sending error response: %w", sendErr) + } + + return nil, fmt.Errorf("path not authorized") + } + + // Validate chunk sequence. + state.mu.Lock() + + if chunk.GetChunkNumber() != state.expectedChunkNum { + expected := state.expectedChunkNum + state.mu.Unlock() + + slog.Warn("chunk order violation", + "transfer_id", transferID, "expected", expected, "got", chunk.GetChunkNumber()) + + m.removeTransfer(transferID) + + sendErr := m.sendTransferError(transferID, "chunk sequence error", stream) + if sendErr != nil { + return nil, fmt.Errorf("sending error response: %w", sendErr) + } + + return nil, fmt.Errorf("chunk sequence error") + } + + state.mu.Unlock() + + return state, nil +} + +// processChunkData creates file if needed and writes chunk data. +func (m *clientFileTransferManager) processChunkData( + chunk *pb.FileChunk, + state *clientFileTransferState, + stream chunkStream, +) error { + transferID := chunk.GetTransferId() + + // Create file on first chunk. + if chunk.GetChunkNumber() == 0 { + file, err := os.OpenFile(state.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, downloadFilePerms) + if err != nil { + slog.Warn("error creating download file", "err", err) + + m.removeTransfer(transferID) + + sendErr := m.sendTransferError(transferID, fmt.Sprintf("cannot create file: %v", err), stream) + if sendErr != nil { + return fmt.Errorf("sending error response: %w", sendErr) + } + + return fmt.Errorf("cannot create file") + } + + state.mu.Lock() + state.file = file + state.mu.Unlock() + } + + // Write chunk data. + state.mu.Lock() + file := state.file + state.mu.Unlock() + + if file != nil && len(chunk.GetChunkData()) > 0 { + _, writeErr := file.Write(chunk.GetChunkData()) + if writeErr != nil { + slog.Warn("error writing to file", "err", writeErr) + + m.removeTransfer(transferID) + + sendErr := m.sendTransferError(transferID, fmt.Sprintf("write error: %v", writeErr), stream) + if sendErr != nil { + return fmt.Errorf("sending error response: %w", sendErr) + } + + return fmt.Errorf("write error") + } + } + + return nil +} + +// handleFileChunk handles a FileChunk from the agent (file download). +// +//nolint:nilerr // errors are already sent to stream, returning nil to continue processing +func (m *clientFileTransferManager) handleFileChunk(chunk *pb.FileChunk, stream chunkStream) error { + transferID := chunk.GetTransferId() + + state, err := m.validateChunkTransfer(chunk, stream) + if err != nil { + // Validation failed, error already sent to stream + return nil + } + + err = m.processChunkData(chunk, state, stream) + if err != nil { + // Error occurred, error already sent to stream + return nil + } + + // Update expected chunk number. + state.mu.Lock() + state.expectedChunkNum++ + state.mu.Unlock() + + // Send acknowledgment. + ackErr := m.sendChunkAcknowledgment(transferID, chunk.GetChunkNumber()+1, stream) + if ackErr != nil { + return fmt.Errorf("sending chunk ack: %w", ackErr) + } + + // Final chunk: close file and send completion. + if chunk.GetIsFinal() { + completeErr := m.sendTransferComplete(transferID, stream) + if completeErr != nil { + return fmt.Errorf("sending completion: %w", completeErr) + } + + m.removeTransfer(transferID) + } + + return nil +} + +// handleFileTransferResponse handles a FileTransferResponse from the agent (acknowledgments). +// Silently processes responses; only logs errors. +func (m *clientFileTransferManager) handleFileTransferResponse(ftRes *pb.FileTransferResponse) { + transferID := ftRes.GetTransferId() + status := ftRes.GetStatus() + + switch status { + case pb.FileTransferResponse_STATUS_ERROR: + slog.Warn("file transfer error", "transfer_id", transferID, "message", ftRes.GetErrorMessage()) + m.removeTransfer(transferID) + case pb.FileTransferResponse_STATUS_TRANSFER_REJECTED: + // The agent refused the transfer. Dropping the state closes the file the + // upload path opened; leaving it would hold the descriptor until exit. + slog.Warn("file transfer rejected by agent", + "transfer_id", transferID, "reason", ftRes.GetErrorMessage()) + m.removeTransfer(transferID) + case pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE: + m.removeTransfer(transferID) + case pb.FileTransferResponse_STATUS_CHUNK_RECEIVED: + // Release the upload sender to read and send the next chunk. A + // non-blocking send keeps the receive loop moving: the channel is buffered + // to one and only ever holds the acknowledgment for the chunk in flight. + if state := m.getTransfer(transferID); state != nil { + select { + case state.ackCh <- ftRes.GetNextChunkExpected(): + default: + } + } + case pb.FileTransferResponse_STATUS_ACCEPTED: + // The agent is ready; the sender is already running. + case pb.FileTransferResponse_STATUS_UNSPECIFIED: + slog.Warn("file transfer response without a status", "transfer_id", transferID) + } +} diff --git a/cmds/dutctl/file_transfer_test.go b/cmds/dutctl/file_transfer_test.go new file mode 100644 index 00000000..330ad60f --- /dev/null +++ b/cmds/dutctl/file_transfer_test.go @@ -0,0 +1,597 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "errors" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" +) + +// recordingStream is a chunkStream that captures every RunRequest sent through +// it, so a test can assert what the manager put on the wire. It is safe for the +// concurrent sends the upload goroutine performs. +type recordingStream struct { + mu sync.Mutex + sent []*pb.RunRequest + err error // when non-nil, Send returns it instead of recording + + // onChunk, when set, is invoked after a FileChunk is recorded. Uploads keep + // one chunk outstanding, so a test that wants the sender to progress has to + // stand in for the agent and acknowledge. + onChunk func(*pb.FileChunk) +} + +func (r *recordingStream) Send(req *pb.RunRequest) error { + r.mu.Lock() + + if r.err != nil { + r.mu.Unlock() + + return r.err + } + + r.sent = append(r.sent, req) + onChunk := r.onChunk + + r.mu.Unlock() + + if onChunk != nil { + if c, ok := req.GetMsg().(*pb.RunRequest_FileChunk); ok { + onChunk(c.FileChunk) + } + } + + return nil +} + +func (r *recordingStream) messages() []*pb.RunRequest { + r.mu.Lock() + defer r.mu.Unlock() + + return append([]*pb.RunRequest(nil), r.sent...) +} + +// responses returns every FileTransferResponse the manager sent. +func (r *recordingStream) responses() []*pb.FileTransferResponse { + var out []*pb.FileTransferResponse + + for _, m := range r.messages() { + if ftr, ok := m.GetMsg().(*pb.RunRequest_FileTransferResponse); ok { + out = append(out, ftr.FileTransferResponse) + } + } + + return out +} + +// chunks returns every FileChunk the manager sent (upload path). +func (r *recordingStream) chunks() []*pb.FileChunk { + var out []*pb.FileChunk + + for _, m := range r.messages() { + if fc, ok := m.GetMsg().(*pb.RunRequest_FileChunk); ok { + out = append(out, fc.FileChunk) + } + } + + return out +} + +func statuses(res []*pb.FileTransferResponse) []pb.FileTransferResponse_Status { + out := make([]pb.FileTransferResponse_Status, len(res)) + for i, r := range res { + out[i] = r.GetStatus() + } + + return out +} + +func downloadChunk(id string, num int32, data []byte, final bool) *pb.FileChunk { + return &pb.FileChunk{TransferId: id, ChunkNumber: num, ChunkData: data, IsFinal: final} +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + + time.Sleep(5 * time.Millisecond) + } + + t.Fatal("condition not met within timeout") +} + +func TestNormalizePath(t *testing.T) { + t.Parallel() + + abs := filepath.Join(t.TempDir(), "f.bin") + + if got := normalizePath(abs); got != abs { + t.Errorf("already-absolute path changed: got %q want %q", got, abs) + } + + // A relative path resolves to an absolute one. + if got := normalizePath("f.bin"); !filepath.IsAbs(got) { + t.Errorf("relative path not made absolute: %q", got) + } + + // Two spellings of the same path normalize equal. + if normalizePath(abs) != normalizePath(filepath.Join(filepath.Dir(abs), ".", "f.bin")) { + t.Errorf("equivalent paths did not normalize equal") + } +} + +func TestIsValidPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + authorized := filepath.Join(dir, "ok.bin") + + m := newClientFileTransferManager([]string{authorized, "unrelated.txt"}) + + if !m.isValidPath(authorized) { + t.Errorf("authorized path rejected") + } + // Same target via a relative spelling from a different cwd still won't match + // unless it resolves to the same absolute path; a clearly different path must + // be rejected. + if m.isValidPath(filepath.Join(dir, "other.bin")) { + t.Errorf("unauthorized path accepted") + } +} + +// Download happy path: chunks arrive in order, the file is written verbatim, +// each chunk is acknowledged, completion is sent, and the transfer is cleaned up. +func TestHandleFileChunk_DownloadHappyPath(t *testing.T) { + t.Parallel() + + content := []byte("hello chunked world") + dest := filepath.Join(t.TempDir(), "out.bin") + + m := newClientFileTransferManager([]string{dest}) + stream := &recordingStream{} + + if err := m.handleDownloadRequest("t1", dest, stream); err != nil { + t.Fatalf("handleDownloadRequest: %v", err) + } + + if err := m.handleFileChunk(downloadChunk("t1", 0, content[:10], false), stream); err != nil { + t.Fatalf("chunk 0: %v", err) + } + + if err := m.handleFileChunk(downloadChunk("t1", 1, content[10:], true), stream); err != nil { + t.Fatalf("chunk 1 (final): %v", err) + } + + got, err := os.ReadFile(dest) + if err != nil { + t.Fatalf("reading downloaded file: %v", err) + } + + if string(got) != string(content) { + t.Errorf("downloaded content = %q, want %q", got, content) + } + + // ACCEPTED, then a CHUNK_RECEIVED per chunk, then TRANSFER_COMPLETE. + want := []pb.FileTransferResponse_Status{ + pb.FileTransferResponse_STATUS_ACCEPTED, + pb.FileTransferResponse_STATUS_CHUNK_RECEIVED, + pb.FileTransferResponse_STATUS_CHUNK_RECEIVED, + pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE, + } + if got := statuses(stream.responses()); !equalStatuses(got, want) { + t.Errorf("response sequence = %v, want %v", got, want) + } + + if m.getTransfer("t1") != nil { + t.Errorf("transfer not removed after completion") + } +} + +// A chunk arriving out of order is rejected and the transfer is torn down, +// rather than being written to the file. +func TestHandleFileChunk_OutOfOrderRejected(t *testing.T) { + t.Parallel() + + dest := filepath.Join(t.TempDir(), "out.bin") + + m := newClientFileTransferManager([]string{dest}) + stream := &recordingStream{} + + if err := m.handleDownloadRequest("t1", dest, stream); err != nil { + t.Fatalf("handleDownloadRequest: %v", err) + } + + // Expected chunk 0, send chunk 1. + if err := m.handleFileChunk(downloadChunk("t1", 1, []byte("x"), false), stream); err != nil { + t.Fatalf("handleFileChunk returned error (should be nil, error sent on stream): %v", err) + } + + if !hasStatus(stream.responses(), pb.FileTransferResponse_STATUS_TRANSFER_REJECTED) { + t.Errorf("expected a TRANSFER_REJECTED, got %v", statuses(stream.responses())) + } + + if m.getTransfer("t1") != nil { + t.Errorf("transfer not removed after sequence error") + } + + if _, err := os.Stat(dest); !errors.Is(err, os.ErrNotExist) { + t.Errorf("destination file should not exist after a rejected chunk") + } +} + +// A chunk for a transfer that was never announced is rejected. +func TestHandleFileChunk_UnknownTransfer(t *testing.T) { + t.Parallel() + + m := newClientFileTransferManager(nil) + stream := &recordingStream{} + + if err := m.handleFileChunk(downloadChunk("ghost", 0, []byte("x"), true), stream); err != nil { + t.Fatalf("handleFileChunk: %v", err) + } + + if !hasStatus(stream.responses(), pb.FileTransferResponse_STATUS_TRANSFER_REJECTED) { + t.Errorf("expected rejection for unknown transfer, got %v", statuses(stream.responses())) + } +} + +// A registered transfer whose path is not among the command arguments is +// refused when its chunks arrive — the path allow-list is enforced per chunk. +func TestHandleFileChunk_PathNotAuthorized(t *testing.T) { + t.Parallel() + + m := newClientFileTransferManager(nil) // no authorized paths + stream := &recordingStream{} + + // A writable destination, so the only thing that can refuse the chunk is the + // allow-list — an unwritable path would pass this test for the wrong reason. + target := filepath.Join(t.TempDir(), "sneaked.bin") + + m.registerTransfer("t1", target, "download") + + if err := m.handleFileChunk(downloadChunk("t1", 0, []byte("x"), true), stream); err != nil { + t.Fatalf("handleFileChunk: %v", err) + } + + if !hasStatus(stream.responses(), pb.FileTransferResponse_STATUS_TRANSFER_REJECTED) { + t.Errorf("expected rejection for unauthorized path, got %v", statuses(stream.responses())) + } + + if m.getTransfer("t1") != nil { + t.Errorf("transfer not removed after authorization failure") + } + + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Errorf("unauthorized destination was created: stat err = %v", err) + } +} + +// A download to a path outside the command arguments is refused up front. +func TestHandleDownloadRequest_Unauthorized(t *testing.T) { + t.Parallel() + + m := newClientFileTransferManager([]string{"/only/this"}) + stream := &recordingStream{} + + if err := m.handleDownloadRequest("t1", "/somewhere/else", stream); err != nil { + t.Fatalf("handleDownloadRequest: %v", err) + } + + if !hasStatus(stream.responses(), pb.FileTransferResponse_STATUS_TRANSFER_REJECTED) { + t.Errorf("expected rejection, got %v", statuses(stream.responses())) + } + + if m.getTransfer("t1") != nil { + t.Errorf("no transfer should be registered for a rejected download") + } +} + +// Upload happy path: the manager accepts, then streams the file back in chunks +// whose concatenation equals the file, ending with a final chunk. +func TestHandleUploadRequest_StreamsFile(t *testing.T) { + t.Parallel() + + content := []byte("upload me please") + src := filepath.Join(t.TempDir(), "src.bin") + + if err := os.WriteFile(src, content, 0o600); err != nil { + t.Fatalf("writing source: %v", err) + } + + m := newClientFileTransferManager([]string{src}) + stream := &recordingStream{} + + // Stand in for the agent: acknowledge each chunk so the sender proceeds. + stream.onChunk = func(c *pb.FileChunk) { + m.handleFileTransferResponse(&pb.FileTransferResponse{ + TransferId: c.GetTransferId(), + Status: pb.FileTransferResponse_STATUS_CHUNK_RECEIVED, + NextChunkExpected: c.GetChunkNumber() + 1, + }) + } + + if err := m.handleUploadRequest("u1", src, stream); err != nil { + t.Fatalf("handleUploadRequest: %v", err) + } + + // The upload streams from a goroutine; wait for the final chunk. + waitFor(t, func() bool { + cs := stream.chunks() + + return len(cs) > 0 && cs[len(cs)-1].GetIsFinal() + }) + + var got []byte + for _, c := range stream.chunks() { + got = append(got, c.GetChunkData()...) + } + + if string(got) != string(content) { + t.Errorf("uploaded bytes = %q, want %q", got, content) + } + + // The client answers the agent's request with the file's measured size, which + // is the only point at which the agent learns how big the upload is. + var announced *pb.FileTransferRequest + + for _, msg := range stream.messages() { + if ftr, ok := msg.GetMsg().(*pb.RunRequest_FileTransferRequest); ok { + announced = ftr.FileTransferRequest + + break + } + } + + if announced == nil { + t.Fatalf("upload was not announced; messages = %d", len(stream.messages())) + } + + if announced.GetDirection() != pb.FileTransferRequest_DIRECTION_UPLOAD { + t.Errorf("announced direction = %v, want UPLOAD", announced.GetDirection()) + } + + if got, want := announced.GetMetadata().GetSize(), int64(len(content)); got != want { + t.Errorf("announced size = %d, want %d", got, want) + } + + // Chunks are numbered from zero, in order. + for i, c := range stream.chunks() { + if c.GetChunkNumber() != int32(i) { + t.Errorf("chunk %d has number %d", i, c.GetChunkNumber()) + } + } + + waitFor(t, func() bool { return m.getTransfer("u1") == nil }) +} + +// An upload of a file not named in the command arguments is refused without +// opening anything. +func TestHandleUploadRequest_Unauthorized(t *testing.T) { + t.Parallel() + + m := newClientFileTransferManager(nil) + stream := &recordingStream{} + + if err := m.handleUploadRequest("u1", "/etc/passwd", stream); err != nil { + t.Fatalf("handleUploadRequest: %v", err) + } + + if !hasStatus(stream.responses(), pb.FileTransferResponse_STATUS_TRANSFER_REJECTED) { + t.Errorf("expected rejection, got %v", statuses(stream.responses())) + } + + if m.getTransfer("u1") != nil { + t.Errorf("no transfer should be registered for a rejected upload") + } +} + +// An unknown transfer direction is reported as an error rather than acted on. +func TestHandleFileTransferRequest_UnknownDirection(t *testing.T) { + t.Parallel() + + m := newClientFileTransferManager(nil) + stream := &recordingStream{} + + req := &pb.FileTransferRequest{ + TransferId: "t1", + Metadata: &pb.FileMetadata{Path: "/x"}, + Direction: pb.FileTransferRequest_DIRECTION_UNSPECIFIED, + } + + if err := m.handleFileTransferRequest(req, stream); err != nil { + t.Fatalf("handleFileTransferRequest: %v", err) + } + + if !hasStatus(stream.responses(), pb.FileTransferResponse_STATUS_TRANSFER_REJECTED) { + t.Errorf("expected an error response for unknown direction, got %v", statuses(stream.responses())) + } +} + +// A terminal FileTransferResponse from the agent clears the local transfer state. +func TestHandleFileTransferResponse_TerminalStatusesClearState(t *testing.T) { + t.Parallel() + + m := newClientFileTransferManager(nil) + m.registerTransfer("done", "/a", "download") + m.registerTransfer("failed", "/b", "download") + + m.handleFileTransferResponse(&pb.FileTransferResponse{ + TransferId: "done", Status: pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE, + }) + m.handleFileTransferResponse(&pb.FileTransferResponse{ + TransferId: "failed", Status: pb.FileTransferResponse_STATUS_ERROR, ErrorMessage: "boom", + }) + + if m.getTransfer("done") != nil { + t.Errorf("completed transfer not cleared") + } + + if m.getTransfer("failed") != nil { + t.Errorf("errored transfer not cleared") + } +} + +func hasStatus(res []*pb.FileTransferResponse, want pb.FileTransferResponse_Status) bool { + for _, r := range res { + if r.GetStatus() == want { + return true + } + } + + return false +} + +func equalStatuses(a, b []pb.FileTransferResponse_Status) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +// unguardedStream models connect's BidiStream, whose Send may not be called +// concurrently with itself. sends is deliberately unsynchronised so the race +// detector reports any overlap; the atomics assert the same thing without it. +type unguardedStream struct { + sends int + inSend atomic.Int32 + maxSeen atomic.Int32 + + // onChunk acknowledges a chunk so the ack-gated upload keeps sending, which + // is what sustains the overlap this test needs. + onChunk func(*pb.FileChunk) +} + +func (s *unguardedStream) Send(req *pb.RunRequest) error { + now := s.inSend.Add(1) + + for { + prev := s.maxSeen.Load() + if now <= prev || s.maxSeen.CompareAndSwap(prev, now) { + break + } + } + + s.sends++ + + time.Sleep(time.Millisecond) // widen the window so overlaps are observable + + s.inSend.Add(-1) + + if s.onChunk != nil { + if c, ok := req.GetMsg().(*pb.RunRequest_FileChunk); ok { + s.onChunk(c.FileChunk) + } + } + + return nil +} + +// TestSendStreamSerializesUploadAndAcks guards the client's send serialisation. +// runRPC sends from three goroutines — this one, the stdin routine, and the +// goroutine sendUploadInChunks spawns — and connect forbids concurrent Send +// calls. Routing them all through sendStream is what keeps that legal; without +// it an upload racing any other message corrupts the stream. +func TestSendStreamSerializesUploadAndAcks(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.bin") + if err := os.WriteFile(path, make([]byte, 4*clientChunkSize), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + raw := &unguardedStream{} + sender := newSendStream(raw) + mgr := newClientFileTransferManager([]string{path}) + + raw.onChunk = func(c *pb.FileChunk) { + mgr.handleFileTransferResponse(&pb.FileTransferResponse{ + TransferId: c.GetTransferId(), + Status: pb.FileTransferResponse_STATUS_CHUNK_RECEIVED, + NextChunkExpected: c.GetChunkNumber() + 1, + }) + } + + // The agent asks for the file: this spawns the upload goroutine. + if err := mgr.handleUploadRequest("up-1", path, sender); err != nil { + t.Fatalf("handleUploadRequest: %v", err) + } + + // Meanwhile the receive loop keeps sending, as it does for any message that + // arrives while an upload is running. + for range 50 { + if err := mgr.sendChunkAcknowledgment("dl-1", 0, sender); err != nil { + t.Fatalf("sendChunkAcknowledgment: %v", err) + } + } + + waitFor(t, func() bool { return mgr.getTransfer("up-1") == nil }) + + if got := raw.maxSeen.Load(); got > 1 { + t.Errorf("observed %d concurrent Send calls, want at most 1", got) + } +} + +// An upload keeps a single chunk outstanding: until the agent acknowledges, +// nothing further is read or sent. Without that the client streams the whole +// file as fast as it can and next_chunk_expected means nothing. +func TestUploadWaitsForChunkAck(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "image.bin") + if err := os.WriteFile(path, make([]byte, 4*clientChunkSize), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + + m := newClientFileTransferManager([]string{path}) + stream := &recordingStream{} // no onChunk: nothing is ever acknowledged + + if err := m.handleUploadRequest("u1", path, stream); err != nil { + t.Fatalf("handleUploadRequest: %v", err) + } + + waitFor(t, func() bool { return len(stream.chunks()) >= 1 }) + + // Give a would-be runaway sender room to send the rest of the file. + time.Sleep(200 * time.Millisecond) + + if got := len(stream.chunks()); got != 1 { + t.Errorf("sent %d chunks without an acknowledgment, want 1", got) + } + + // Acknowledging releases exactly one more chunk. + m.handleFileTransferResponse(&pb.FileTransferResponse{ + TransferId: "u1", + Status: pb.FileTransferResponse_STATUS_CHUNK_RECEIVED, + NextChunkExpected: 1, + }) + + waitFor(t, func() bool { return len(stream.chunks()) == 2 }) + + // Dropping the transfer must release the sender rather than leak it. + m.removeTransfer("u1") + + time.Sleep(100 * time.Millisecond) + + if got := len(stream.chunks()); got != 2 { + t.Errorf("sender kept going after the transfer was dropped: %d chunks", got) + } +} diff --git a/cmds/dutctl/rpc.go b/cmds/dutctl/rpc.go index 161997f8..ddfad1d3 100644 --- a/cmds/dutctl/rpc.go +++ b/cmds/dutctl/rpc.go @@ -10,9 +10,7 @@ import ( "errors" "fmt" "io" - "io/fs" "log/slog" - "os" "strings" "time" @@ -224,7 +222,7 @@ func (app *application) detailsRPC(ctx context.Context, device, command, keyword // error from a worker goroutine (stream send/receive or file I/O). A connect // status from the agent surfaces through the returned error; exit() renders it. // -//nolint:funlen,cyclop,gocognit,maintidx // coordinates two streaming worker goroutines; inherently branchy +//nolint:funlen,cyclop,gocognit // coordinates two streaming worker goroutines; inherently branchy func (app *application) runRPC(ctx context.Context, device, command string, cmdArgs []string) error { const numWorkers = 2 // The send and receive worker goroutines @@ -240,6 +238,12 @@ func (app *application) runRPC(ctx context.Context, device, command string, cmdA stream := app.rpcClient.Run(runCtx) stream.RequestHeader().Set(headers.User, app.user) + // Every send goes through sender; stream.Receive stays on the raw stream. + // Connect forbids concurrent Send calls, and below there are three senders: + // this goroutine, the stdin routine, and the file-transfer manager's upload + // routine. + sender := newSendStream(stream) + req := &pb.RunRequest{ Msg: &pb.RunRequest_Command{ Command: &pb.Command{ @@ -250,7 +254,7 @@ func (app *application) runRPC(ctx context.Context, device, command string, cmdA }, } - err := stream.Send(req) + err := sender.Send(req) if err != nil { return err } @@ -263,6 +267,11 @@ func (app *application) runRPC(ctx context.Context, device, command string, cmdA "args": strings.Join(cmdArgs, " "), } + // ftManager drives the client side of the chunked file-transfer protocol: + // answering the agent's upload requests with chunks and writing downloaded + // chunks to disk. cmdArgs scope which paths the client is willing to serve. + ftManager := newClientFileTransferManager(cmdArgs) + // Receive routine go func() { defer cancelRunCtx() @@ -319,58 +328,22 @@ func (app *application) runRPC(ctx context.Context, device, command string, cmdA case *pb.Console_Stdin: slog.Warn("unexpected console stdin from agent", "data", string(consoleData.Stdin)) } - case *pb.RunResponse_FileRequest: - path := msg.FileRequest.GetPath() - slog.Debug("file requested by agent", "path", path) - - content, err := os.ReadFile(path) - if err != nil { - errChan <- fmt.Errorf("reading requested file %q: %w", path, err) + case *pb.RunResponse_FileTransferRequest: + ftErr := ftManager.handleFileTransferRequest(msg.FileTransferRequest, sender) + if ftErr != nil { + errChan <- ftErr return } - - err = stream.Send(&pb.RunRequest{ - Msg: &pb.RunRequest_File{ - File: &pb.File{ - Path: path, - Content: content, - }, - }, - }) - if err != nil { - errChan <- fmt.Errorf("sending requested file %q: %w", path, err) - - return - } - - app.formatter.WriteContent(output.Content{ - Type: output.TypeFileTransfer, - Data: output.FileTransfer{Direction: "sent", Path: path, Bytes: len(content)}, - Metadata: metadata, - }) - case *pb.RunResponse_File: - path := msg.File.GetPath() - content := msg.File.GetContent() - - if len(content) == 0 { - slog.Warn("received empty file content", "path", path) - } - - perm := 0600 - - err = os.WriteFile(path, content, fs.FileMode(perm)) - if err != nil { - errChan <- fmt.Errorf("saving received file %q: %w", path, err) + case *pb.RunResponse_FileChunk: + chunkErr := ftManager.handleFileChunk(msg.FileChunk, sender) + if chunkErr != nil { + errChan <- chunkErr return } - - app.formatter.WriteContent(output.Content{ - Type: output.TypeFileTransfer, - Data: output.FileTransfer{Direction: "received", Path: path, Bytes: len(content)}, - Metadata: metadata, - }) + case *pb.RunResponse_FileTransferResponse: + ftManager.handleFileTransferResponse(msg.FileTransferResponse) default: slog.Warn("unexpected message type", "type", fmt.Sprintf("%T", msg)) @@ -409,7 +382,7 @@ func (app *application) runRPC(ctx context.Context, device, command string, cmdA return } - err = stream.Send(&pb.RunRequest{ + err = sender.Send(&pb.RunRequest{ Msg: &pb.RunRequest_Console{ Console: &pb.Console{ Data: &pb.Console_Stdin{ diff --git a/cmds/exp/dutserver/rpc.go b/cmds/exp/dutserver/rpc.go index 4f1d73f7..d40a8b44 100644 --- a/cmds/exp/dutserver/rpc.go +++ b/cmds/exp/dutserver/rpc.go @@ -451,8 +451,12 @@ func requestKind(req *pb.RunRequest) string { return "command" case req.GetConsole() != nil: return "console" - case req.GetFile() != nil: - return "file" + case req.GetFileTransferRequest() != nil: + return "file-transfer-request" + case req.GetFileChunk() != nil: + return "file-chunk" + case req.GetFileTransferResponse() != nil: + return "file-transfer-response" default: return "unknown" } @@ -466,10 +470,12 @@ func responseKind(res *pb.RunResponse) string { return "print" case res.GetConsole() != nil: return "console" - case res.GetFileRequest() != nil: - return "file-request" - case res.GetFile() != nil: - return "file" + case res.GetFileTransferRequest() != nil: + return "file-transfer-request" + case res.GetFileChunk() != nil: + return "file-chunk" + case res.GetFileTransferResponse() != nil: + return "file-transfer-response" default: return "unknown" } diff --git a/go.mod b/go.mod index d9788790..e9595d2a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/bougou/go-ipmi v0.8.2 github.com/go-playground/validator/v10 v10.30.3 github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.1.2 github.com/stianeikeland/go-rpio/v4 v4.6.0 go.bug.st/serial v1.8.0 golang.org/x/crypto v0.54.0 @@ -20,7 +21,6 @@ require ( github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/google/uuid v1.1.2 // indirect github.com/kr/pretty v0.3.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/internal/dutagent/session/broker.go b/internal/dutagent/session/broker.go index 8b537b1c..81f3d100 100644 --- a/internal/dutagent/session/broker.go +++ b/internal/dutagent/session/broker.go @@ -43,8 +43,11 @@ func (b *Broker) init() { b.session.stdinCh = make(chan []byte) b.session.stdoutCh = make(chan []byte) b.session.stderrCh = make(chan []byte) - b.session.fileReqCh = make(chan string) - b.session.fileCh = make(chan chan []byte) + b.session.shutdownCh = make(chan struct{}) + b.session.outputStopCh = make(chan struct{}) + b.session.fileTransferNotifyCh = make(chan struct{}, 1) + b.session.activeUploads = make(map[string]*uploadState) + b.session.activeDownloads = make(map[string]*downloadState) // Buffer equals number of workers so error sends never block. b.errCh = make(chan error, numWorkers) @@ -82,8 +85,34 @@ func (b *Broker) Start(ctx context.Context, s Stream) (module.Session, <-chan er b.toClient(workerCtx, workerCancel) b.fromClient(workerCtx, workerCancel) + // Release in-flight transfers as soon as the workers are cancelled, without + // waiting for them to return. fromClientWorker can be blocked in an upload's + // io.Pipe write, which no context can interrupt and only CloseWithError + // unblocks — and it is one of the workers b.wg waits on below. Aborting from + // wg.Wait alone would therefore never run. Terminates once the workers do: + // both call workerCancel when they exit. + go func() { + <-workerCtx.Done() + b.session.abortTransfers() + }() + + // Close the console writers' stop signal on whichever comes first. Both + // eventually fire, so this goroutine always terminates. + go func() { + select { + case <-workerCtx.Done(): + case <-b.session.shutdownCh: + } + + close(b.session.outputStopCh) + }() + go func() { b.wg.Wait() + // Both workers have exited. Release any transfer registered after the + // cancellation sweep above, so WaitForTransfers (and thus graceful + // shutdown) cannot block forever. abortTransfers is idempotent. + b.session.abortTransfers() close(b.errCh) }() }) @@ -92,6 +121,17 @@ func (b *Broker) Start(ctx context.Context, s Stream) (module.Session, <-chan er return &b.session, b.errCh } +// Shutdown begins graceful shutdown of the session: module output stops being +// forwarded while the workers finish any in-flight file transfers. +func (b *Broker) Shutdown() { + b.session.Shutdown() +} + +// WaitForTransfersToComplete blocks until every active file transfer has finished. +func (b *Broker) WaitForTransfersToComplete() { + b.session.WaitForTransfers() +} + func (b *Broker) toClient(ctx context.Context, cancel context.CancelFunc) { // Scope the downstream (agent → client) flow; the worker and its chanio // reader inherit it from ctx. diff --git a/internal/dutagent/session/broker_test.go b/internal/dutagent/session/broker_test.go index d462acb6..c4b3e3b9 100644 --- a/internal/dutagent/session/broker_test.go +++ b/internal/dutagent/session/broker_test.go @@ -10,7 +10,6 @@ import ( "io" "runtime" "strings" - "sync" "testing" "time" @@ -285,14 +284,15 @@ func TestBroker_DualErrors(t *testing.T) { // module-facing session call must unblock via the frozen done signal instead of // wedging on a channel whose worker peer is gone. Output methods drop; the // Console reader reports io.EOF and the writers io.ErrClosedPipe; the file -// methods return an error. Pre-fix these were bare channel ops that blocked the -// module goroutine forever. +// methods refuse with ErrSessionClosed. Pre-fix the output/console ops were bare +// channel ops that blocked the module goroutine forever. func TestBrokerSessionCallsUnblockAfterTeardown(t *testing.T) { b := &Broker{} // Immediate EOF makes fromClientWorker return, which cancels the workers and // closes the session's done signal; errCh closing confirms both are gone. stream := &testStream{recvErrs: []error{nil}} - sess, errCh := b.Start(context.Background(), stream) + ctx := context.Background() + sess, errCh := b.Start(ctx, stream) if errs := collectErrors(t, errCh, time.Second); len(errs) != 0 { t.Fatalf("unexpected errors on EOF teardown: %v", errs) @@ -316,8 +316,8 @@ func TestBrokerSessionCallsUnblockAfterTeardown(t *testing.T) { _, stdoutErr = stdout.Write([]byte("x")) _, stderrErr = stderr.Write([]byte("x")) _, stdinErr = io.ReadAll(stdin) - _, reqErr = sess.RequestFile("f") - sendFileErr = sess.SendFile("f", strings.NewReader("data")) + _, reqErr = sess.RequestFile(ctx, "f") + sendFileErr = sess.SendFile(ctx, "f", 4, strings.NewReader("data")) }() select { @@ -338,35 +338,18 @@ func TestBrokerSessionCallsUnblockAfterTeardown(t *testing.T) { t.Errorf("stdin io.ReadAll err = %v, want nil (EOF terminates ReadAll)", stdinErr) } - if !errors.Is(reqErr, errSessionClosed) { - t.Errorf("RequestFile err = %v, want errSessionClosed", reqErr) + // RequestFile and SendFile refuse outright once the workers are gone. Letting + // them register would account for a transfer on transferWg that nothing can + // ever carry or release, so the refusal is what keeps the count balanced. + if !errors.Is(reqErr, ErrSessionClosed) { + t.Errorf("RequestFile err = %v, want ErrSessionClosed", reqErr) } - if !errors.Is(sendFileErr, errSessionClosed) { - t.Errorf("SendFile err = %v, want errSessionClosed", sendFileErr) + if !errors.Is(sendFileErr, ErrSessionClosed) { + t.Errorf("SendFile err = %v, want ErrSessionClosed", sendFileErr) } } -// TestBackendCurrentFileRace guards the mutex on currentFile: it is read and -// written from three goroutines (SendFile on the module goroutine, and both -// broker workers) with no channel handing it between them. Concurrent access -// without the lock is a data race; run under -race this fails if the guarding -// mutex is dropped. -func TestBackendCurrentFileRace(t *testing.T) { - b := &backend{} - - var wg sync.WaitGroup - - for range 50 { - wg.Add(2) - - go func() { defer wg.Done(); b.setCurrentFile("image.bin") }() - go func() { defer wg.Done(); _ = b.currentFileName() }() - } - - wg.Wait() -} - // TestBrokerReceiveLoopExitsOnCancel is a regression test for the receive-loop // goroutine leak (3b): when the broker is cancelled while stream.Receive is // blocked, the inner goroutine must exit once Receive returns — its resCh send is diff --git a/internal/dutagent/session/filetransfer_test.go b/internal/dutagent/session/filetransfer_test.go new file mode 100644 index 00000000..3ccc9fae --- /dev/null +++ b/internal/dutagent/session/filetransfer_test.go @@ -0,0 +1,682 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package session + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "sync" + "testing" + "time" + + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" +) + +// uploadStream learns the upload transfer ID from the FileTransferRequest the +// agent announces, then feeds chunks for it and stays connected. +type uploadStream struct { + idCh chan string + id string + n int + stop chan struct{} +} + +func (s *uploadStream) Send(res *pb.RunResponse) error { + if r := res.GetFileTransferRequest(); r != nil && r.GetDirection() == pb.FileTransferRequest_DIRECTION_UPLOAD { + select { + case s.idCh <- r.GetTransferId(): + default: + } + } + + return nil +} + +func (s *uploadStream) Receive() (*pb.RunRequest, error) { + if s.id == "" { + s.id = <-s.idCh + } + + if s.n >= 3 { + <-s.stop // the client stays connected but sends nothing further + + return nil, context.Canceled + } + + num := int32(s.n) + s.n++ + + return &pb.RunRequest{Msg: &pb.RunRequest_FileChunk{FileChunk: &pb.FileChunk{ + TransferId: s.id, + ChunkNumber: num, + ChunkData: make([]byte, 1024), + }}}, nil +} + +// TestUploadWriteUnblocksOnCancel guards the cancellation watchdog in +// Broker.Start. An upload chunk is written into an io.Pipe that only the module +// drains, so a module that abandons its reader leaves fromClientWorker blocked +// in a write no context can interrupt. Only abortTransfers releases it — and +// aborting from wg.Wait alone cannot, because the blocked worker is one of the +// two being waited on. Without the watchdog the broker never tears down, the +// worker leaks for the process lifetime, and the RPC hangs. +func TestUploadWriteUnblocksOnCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + stream := &uploadStream{idCh: make(chan string, 1), stop: make(chan struct{})} + + defer close(stream.stop) + + b := &Broker{} + sess, errCh := b.Start(ctx, stream) + + if _, err := sess.RequestFile(ctx, "image.bin"); err != nil { + t.Fatalf("RequestFile: %v", err) + } + + // Give the announced transfer time to reach registerUploadChunk and block + // there; the module deliberately never reads the returned reader. + time.Sleep(200 * time.Millisecond) + + cancel() + + select { + case <-errCh: + case <-time.After(5 * time.Second): + t.Fatal("broker did not tear down after cancel: fromClientWorker wedged in pipe.Write") + } +} + +// TestRequestFileRefusedDuringShutdown guards the transferWg accounting. +// processFileTransfers stops announcing uploads once shutdown begins, so a +// transfer registered after that point could never be announced, completed or +// released — and executeModules waits on transferWg before cancelling the +// workers, leaving nothing to break the wait. +func TestRequestFileRefusedDuringShutdown(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stream := &testStream{recvBlock: true, unblockCh: make(chan struct{})} + + b := &Broker{} + sess, _ := b.Start(ctx, stream) + + b.Shutdown() + + _, err := sess.RequestFile(ctx, "image.bin") + if !errors.Is(err, ErrSessionShuttingDown) { + t.Errorf("RequestFile err = %v, want ErrSessionShuttingDown", err) + } + + sendErr := sess.SendFile(ctx, "out.bin", 4, strings.NewReader("data")) + if !errors.Is(sendErr, ErrSessionShuttingDown) { + t.Errorf("SendFile err = %v, want ErrSessionShuttingDown", sendErr) + } + + done := make(chan struct{}) + + go func() { b.WaitForTransfersToComplete(); close(done) }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("WaitForTransfers blocked on a transfer registered after shutdown") + } +} + +// rejectStream accepts the download announcement, answers TRANSFER_REJECTED, +// then stays connected and counts the chunks that keep arriving. +type rejectStream struct { + mu sync.Mutex + chunks int + rejected bool + idCh chan string + stop chan struct{} +} + +func (s *rejectStream) Send(res *pb.RunResponse) error { + s.mu.Lock() + defer s.mu.Unlock() + + if r := res.GetFileTransferRequest(); r != nil && r.GetDirection() == pb.FileTransferRequest_DIRECTION_DOWNLOAD { + select { + case s.idCh <- r.GetTransferId(): + default: + } + } + + if res.GetFileChunk() != nil && s.rejected { + s.chunks++ + } + + return nil +} + +func (s *rejectStream) Receive() (*pb.RunRequest, error) { + id, ok := <-s.idCh + if !ok { + <-s.stop + + return nil, context.Canceled + } + + close(s.idCh) // any later Receive parks until the test finishes + + s.mu.Lock() + s.rejected = true + s.mu.Unlock() + + return &pb.RunRequest{Msg: &pb.RunRequest_FileTransferResponse{FileTransferResponse: &pb.FileTransferResponse{ + TransferId: id, + Status: pb.FileTransferResponse_STATUS_TRANSFER_REJECTED, + ErrorMessage: "destination not in command arguments", + }}}, nil +} + +// TestDownloadRejectionStopsStreaming guards the client's only defence against +// an agent writing to a path the user never named: the client answers +// TRANSFER_REJECTED, and the agent must drop the download. Handling the status +// for uploads alone lets the agent stream the whole file to a client that has +// already refused it. +func TestDownloadRejectionStopsStreaming(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const size = 32 * 1024 * 1024 // 32 chunks, enough to catch a full stream-out + + stream := &rejectStream{idCh: make(chan string, 1), stop: make(chan struct{})} + + defer close(stream.stop) + + b := &Broker{} + sess, _ := b.Start(ctx, stream) + + if err := sess.SendFile(ctx, "/etc/shadow", size, bytes.NewReader(make([]byte, size))); err != nil { + t.Fatalf("SendFile: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + if left := b.session.getActiveDownloads(); len(left) != 0 { + t.Errorf("download still active after rejection: %v", left) + } + + stream.mu.Lock() + settled := stream.chunks + stream.mu.Unlock() + + // A chunk already in flight when the rejection is processed is acceptable; + // continuing to stream is not. + time.Sleep(300 * time.Millisecond) + + stream.mu.Lock() + after := stream.chunks + stream.mu.Unlock() + + if after != settled { + t.Errorf("agent kept streaming after rejection: %d chunks, then %d", settled, after) + } + + if after > 4 { + t.Errorf("agent sent %d chunks after the client rejected the download", after) + } +} + +// scriptedStream drives a full transfer from the client's side: it records what +// the agent sends and answers each message the way a well-behaved client would. +type scriptedStream struct { + mu sync.Mutex + sent []*pb.RunResponse + + reply chan *pb.RunRequest + stop chan struct{} +} + +func newScriptedStream() *scriptedStream { + return &scriptedStream{ + reply: make(chan *pb.RunRequest, 64), + stop: make(chan struct{}), + } +} + +func (s *scriptedStream) Send(res *pb.RunResponse) error { + s.mu.Lock() + s.sent = append(s.sent, res) + s.mu.Unlock() + + return nil +} + +func (s *scriptedStream) Receive() (*pb.RunRequest, error) { + select { + case req := <-s.reply: + return req, nil + case <-s.stop: + return nil, io.EOF + } +} + +func (s *scriptedStream) responses() []*pb.RunResponse { + s.mu.Lock() + defer s.mu.Unlock() + + return append([]*pb.RunResponse(nil), s.sent...) +} + +// downloadChunks returns the chunks the agent has streamed so far. +func (s *scriptedStream) downloadChunks() []*pb.FileChunk { + var out []*pb.FileChunk + + for _, res := range s.responses() { + if c := res.GetFileChunk(); c != nil { + out = append(out, c) + } + } + + return out +} + +// awaitAnnouncement waits for the agent to announce a transfer in dir and +// returns its ID. +func awaitAnnouncement(t *testing.T, s *scriptedStream, dir pb.FileTransferRequest_Direction) string { + t.Helper() + + deadline := time.After(5 * time.Second) + + for { + for _, res := range s.responses() { + if r := res.GetFileTransferRequest(); r != nil && r.GetDirection() == dir { + return r.GetTransferId() + } + } + + select { + case <-deadline: + t.Fatalf("no %v announcement within timeout", dir) + case <-time.After(5 * time.Millisecond): + } + } +} + +func awaitStatus(t *testing.T, s *scriptedStream, want pb.FileTransferResponse_Status) { + t.Helper() + + deadline := time.After(5 * time.Second) + + for { + for _, res := range s.responses() { + if r := res.GetFileTransferResponse(); r != nil && r.GetStatus() == want { + return + } + } + + select { + case <-deadline: + t.Fatalf("agent never reported %v", want) + case <-time.After(5 * time.Millisecond): + } + } +} + +// TestDownloadHappyPath covers the agent's download state machine end to end: +// announce, stream every chunk in order, mark the last one final, and release +// the transfer only once the client confirms completion. +func TestDownloadHappyPath(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const size = 3*chunkSize + 512 // four chunks, last one short + + content := make([]byte, size) + for i := range content { + content[i] = byte(i % 251) + } + + stream := newScriptedStream() + defer close(stream.stop) + + b := &Broker{} + sess, _ := b.Start(ctx, stream) + + // A short reader: legal per io.Reader, and the case that breaks a chunk_offset + // computed as chunkNumber*chunkSize rather than from the bytes actually sent. + src := io.NopCloser(&shortReader{src: bytes.NewReader(content), max: chunkSize / 3}) + + if err := sess.SendFile(ctx, "out.bin", size, src); err != nil { + t.Fatalf("SendFile: %v", err) + } + + id := awaitAnnouncement(t, stream, pb.FileTransferRequest_DIRECTION_DOWNLOAD) + + // Accept, then acknowledge chunks until the agent marks one final. + stream.reply <- &pb.RunRequest{Msg: &pb.RunRequest_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: id, Status: pb.FileTransferResponse_STATUS_ACCEPTED, + }, + }} + + deadline := time.After(10 * time.Second) + + for { + cs := stream.downloadChunks() + if len(cs) > 0 && cs[len(cs)-1].GetIsFinal() { + break + } + + select { + case <-deadline: + t.Fatalf("download did not finish; %d chunks sent", len(cs)) + case <-time.After(5 * time.Millisecond): + } + } + + chunks := stream.downloadChunks() + + var ( + got []byte + offset int64 + ) + + for i, c := range chunks { + if c.GetChunkNumber() != int32(i) { + t.Errorf("chunk %d numbered %d", i, c.GetChunkNumber()) + } + + if c.GetChunkOffset() != offset { + t.Errorf("chunk %d offset = %d, want %d", i, c.GetChunkOffset(), offset) + } + + offset += int64(len(c.GetChunkData())) + got = append(got, c.GetChunkData()...) + } + + if !bytes.Equal(got, content) { + t.Errorf("streamed %d bytes, want %d (content mismatch)", len(got), len(content)) + } + + if final := chunks[len(chunks)-1]; !final.GetIsFinal() { + t.Error("last chunk not marked final") + } + + // The transfer stays registered until the client confirms. + if left := b.session.getActiveDownloads(); len(left) != 1 { + t.Errorf("download released before the client confirmed: %v", left) + } + + stream.reply <- &pb.RunRequest{Msg: &pb.RunRequest_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: id, Status: pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE, + }, + }} + + done := make(chan struct{}) + + go func() { b.WaitForTransfersToComplete(); close(done) }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("transfer never released after TRANSFER_COMPLETE") + } +} + +// TestUploadHappyPath covers the agent's upload state machine: announce the +// request, record the size the client reports, reassemble the chunks through the +// module's reader, and complete. +func TestUploadHappyPath(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + content := []byte("firmware image contents") + + stream := newScriptedStream() + defer close(stream.stop) + + b := &Broker{} + sess, _ := b.Start(ctx, stream) + + reader, err := sess.RequestFile(ctx, "image.bin") + if err != nil { + t.Fatalf("RequestFile: %v", err) + } + + read := make(chan []byte, 1) + + go func() { + got, _ := io.ReadAll(reader) + read <- got + }() + + id := awaitAnnouncement(t, stream, pb.FileTransferRequest_DIRECTION_UPLOAD) + + // The client answers with the file's real size, then sends it. + stream.reply <- &pb.RunRequest{Msg: &pb.RunRequest_FileTransferRequest{ + FileTransferRequest: &pb.FileTransferRequest{ + TransferId: id, + Direction: pb.FileTransferRequest_DIRECTION_UPLOAD, + Metadata: &pb.FileMetadata{Path: "image.bin", Size: int64(len(content))}, + }, + }} + + awaitStatus(t, stream, pb.FileTransferResponse_STATUS_ACCEPTED) + + if got := b.session.getUpload(id); got == nil { + t.Fatal("upload disappeared after acceptance") + } else if size := got.metadata.GetSize(); size != int64(len(content)) { + t.Errorf("recorded upload size = %d, want %d", size, len(content)) + } + + stream.reply <- &pb.RunRequest{Msg: &pb.RunRequest_FileChunk{FileChunk: &pb.FileChunk{ + TransferId: id, ChunkNumber: 0, ChunkData: content, IsFinal: true, + }}} + + select { + case got := <-read: + if !bytes.Equal(got, content) { + t.Errorf("module read %q, want %q", got, content) + } + case <-time.After(5 * time.Second): + t.Fatal("module never received the uploaded file") + } + + awaitStatus(t, stream, pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE) +} + +// TestUploadChunkOutOfOrderFailsRun guards the sequence check. There is no +// resend path, so a gap in the chunk numbering is a protocol violation the run +// cannot recover from — it must surface as ErrBadFileTransfer rather than be +// answered in-band and forgotten. +func TestUploadChunkOutOfOrderFailsRun(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + stream := newScriptedStream() + defer close(stream.stop) + + b := &Broker{} + sess, errCh := b.Start(ctx, stream) + + reader, err := sess.RequestFile(ctx, "image.bin") + if err != nil { + t.Fatalf("RequestFile: %v", err) + } + + go io.Copy(io.Discard, reader) //nolint:errcheck // drains the pipe so writes proceed + + id := awaitAnnouncement(t, stream, pb.FileTransferRequest_DIRECTION_UPLOAD) + + // Chunk 1 without chunk 0. + stream.reply <- &pb.RunRequest{Msg: &pb.RunRequest_FileChunk{FileChunk: &pb.FileChunk{ + TransferId: id, ChunkNumber: 1, ChunkData: []byte("out of order"), + }}} + + select { + case workerErr := <-errCh: + if !errors.Is(workerErr, ErrBadFileTransfer) { + t.Errorf("worker err = %v, want ErrBadFileTransfer", workerErr) + } + case <-time.After(5 * time.Second): + t.Fatal("out-of-order chunk did not fail the run") + } +} + +// shortReader delivers at most max bytes per Read, which io.Reader explicitly +// permits and which real pipes and sockets do routinely. +type shortReader struct { + src *bytes.Reader + max int +} + +func (r *shortReader) Read(p []byte) (int, error) { + if len(p) > r.max { + p = p[:r.max] + } + + return r.src.Read(p) +} + +// TestRequestFileContextBoundsTransfer: a module that gives RequestFile a +// deadline gets one. The reader fails with the context's cause and the transfer +// is released, so the run is not held open by a client that stopped sending. +func TestRequestFileContextBoundsTransfer(t *testing.T) { + stream := newScriptedStream() + defer close(stream.stop) + + b := &Broker{} + sess, _ := b.Start(context.Background(), stream) + + // The module bounds its own transfer, exactly as it would bound a subprocess. + transferCtx, cancelTransfer := context.WithCancel(context.Background()) + + reader, err := sess.RequestFile(transferCtx, "image.bin") + if err != nil { + t.Fatalf("RequestFile: %v", err) + } + + awaitAnnouncement(t, stream, pb.FileTransferRequest_DIRECTION_UPLOAD) + + readErr := make(chan error, 1) + + go func() { + _, err := io.ReadAll(reader) + readErr <- err + }() + + // The client goes quiet. Only the module's own context can end this. + cancelTransfer() + + select { + case err := <-readErr: + if err == nil { + t.Error("reader returned success after the transfer was cancelled") + } + case <-time.After(5 * time.Second): + t.Fatal("cancelling the context did not unblock the module's reader") + } + + if left := b.session.getActiveUploads(); len(left) != 0 { + t.Errorf("upload still registered after cancellation: %v", left) + } + + done := make(chan struct{}) + + go func() { b.WaitForTransfersToComplete(); close(done) }() + + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("cancelled transfer was never released from the wait group") + } +} + +// closeSpyReader reports whether the session closed it, which is the contract +// SendFile documents for a reader it has taken ownership of. +type closeSpyReader struct { + *bytes.Reader + closed chan struct{} + once sync.Once +} + +func (r *closeSpyReader) Close() error { + r.once.Do(func() { close(r.closed) }) + + return nil +} + +// TestSendFileContextBoundsTransfer: cancelling after SendFile has returned +// still aborts the in-flight download and closes the reader. SendFile is +// fire-and-forget, so the context is the only handle the module keeps on it. +func TestSendFileContextBoundsTransfer(t *testing.T) { + stream := newScriptedStream() + defer close(stream.stop) + + b := &Broker{} + sess, _ := b.Start(context.Background(), stream) + + const size = 32 * 1024 * 1024 + + src := &closeSpyReader{ + Reader: bytes.NewReader(make([]byte, size)), + closed: make(chan struct{}), + } + + transferCtx, cancelTransfer := context.WithCancel(context.Background()) + + if err := sess.SendFile(transferCtx, "out.bin", size, src); err != nil { + t.Fatalf("SendFile: %v", err) + } + + awaitAnnouncement(t, stream, pb.FileTransferRequest_DIRECTION_DOWNLOAD) + + // SendFile has already returned; the context is still in charge. + cancelTransfer() + + select { + case <-src.closed: + case <-time.After(5 * time.Second): + t.Fatal("cancelling the context did not close the reader the session owned") + } + + deadline := time.After(5 * time.Second) + + for { + if len(b.session.getActiveDownloads()) == 0 { + break + } + + select { + case <-deadline: + t.Fatal("download still registered after cancellation") + case <-time.After(5 * time.Millisecond): + } + } +} + +// A context already cancelled when the module calls in is refused outright, +// rather than registering a transfer that is torn down a moment later. +func TestTransferRefusedOnCancelledContext(t *testing.T) { + stream := newScriptedStream() + defer close(stream.stop) + + b := &Broker{} + sess, _ := b.Start(context.Background(), stream) + + dead, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := sess.RequestFile(dead, "image.bin"); !errors.Is(err, context.Canceled) { + t.Errorf("RequestFile err = %v, want context.Canceled", err) + } + + if err := sess.SendFile(dead, "out.bin", 4, strings.NewReader("data")); !errors.Is(err, context.Canceled) { + t.Errorf("SendFile err = %v, want context.Canceled", err) + } + + if n := len(b.session.getActiveUploads()) + len(b.session.getActiveDownloads()); n != 0 { + t.Errorf("%d transfers registered despite a cancelled context", n) + } +} diff --git a/internal/dutagent/session/sendserialize_test.go b/internal/dutagent/session/sendserialize_test.go new file mode 100644 index 00000000..1caf9227 --- /dev/null +++ b/internal/dutagent/session/sendserialize_test.go @@ -0,0 +1,75 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package session + +import ( + "io" + "sync" + "sync/atomic" + "testing" + "time" + + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" +) + +// concurrencyStream records the maximum number of Send calls that overlap. +// The connect BidiStream forbids concurrent Send, so sendToClient must keep +// this at 1 no matter how many goroutines call it. +type concurrencyStream struct { + inSend atomic.Int32 + maxSeen atomic.Int32 + calls atomic.Int32 +} + +func (c *concurrencyStream) Send(_ *pb.RunResponse) error { + now := c.inSend.Add(1) + + for { + prev := c.maxSeen.Load() + if now <= prev || c.maxSeen.CompareAndSwap(prev, now) { + break + } + } + + time.Sleep(time.Millisecond) // widen the window so overlaps are observable + + c.calls.Add(1) + c.inSend.Add(-1) + + return nil +} + +func (c *concurrencyStream) Receive() (*pb.RunRequest, error) { return nil, io.EOF } + +// TestSendToClientSerializes verifies that sendToClient serialises stream sends, +// which is required because both workers send on the same connect BidiStream. +func TestSendToClientSerializes(t *testing.T) { + s := &backend{} + stream := &concurrencyStream{} + + const goroutines = 50 + + var wg sync.WaitGroup + + wg.Add(goroutines) + + for range goroutines { + go func() { + defer wg.Done() + + _ = s.sendToClient(stream, &pb.RunResponse{}) + }() + } + + wg.Wait() + + if got := stream.maxSeen.Load(); got > 1 { + t.Errorf("observed %d concurrent Send calls, want at most 1", got) + } + + if got := stream.calls.Load(); got != goroutines { + t.Errorf("Send called %d times, want %d", got, goroutines) + } +} diff --git a/internal/dutagent/session/session.go b/internal/dutagent/session/session.go index fef596fd..5fc0678b 100644 --- a/internal/dutagent/session/session.go +++ b/internal/dutagent/session/session.go @@ -5,6 +5,7 @@ package session import ( + "context" "errors" "fmt" "io" @@ -13,43 +14,153 @@ import ( "github.com/BlindspotSoftware/dutctl/internal/chanio" "github.com/BlindspotSoftware/dutctl/internal/log" + "github.com/google/uuid" + + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" +) + +// chunkSize is the maximum size of a single file chunk (1MB). +const chunkSize = 1024 * 1024 + +// Errors returned by RequestFile and SendFile when the session can no longer +// carry a new transfer. Both are terminal for the module: there is no client +// left to serve the transfer, so a module should return them rather than retry. +var ( + // ErrSessionShuttingDown reports that graceful shutdown has begun. New + // transfers are refused from this point because toClientWorker no longer + // announces uploads, so a transfer registered now would be counted by + // transferWg and never completed — wedging WaitForTransfers. + ErrSessionShuttingDown = errors.New("session is shutting down") + + // ErrSessionClosed reports that the broker's workers have been torn down, + // so nothing remains to carry a transfer. + ErrSessionClosed = errors.New("session is closed") ) -// errSessionClosed is returned by the module-facing methods when the session -// was torn down (its workers exited) before a transfer could complete. It is -// opaque and reported to the module as-is. Not meant to be matched. -var errSessionClosed = errors.New("session closed") +// uploadState represents an active upload from client to agent (a file the +// module requested via RequestFile). Chunks arriving from the client are written +// to file; the module reads them through reader. +type uploadState struct { + transferID string + metadata *pb.FileMetadata + lastChunk int32 // last received chunk number, for sequence validation + file *io.PipeWriter + reader *io.PipeReader + requestSent bool // whether the initial FileTransferRequest has been announced + mu sync.Mutex + + // done is closed once the transfer is released, freeing the context watcher. + done chan struct{} + closeOnce sync.Once +} + +// close releases the upload once, failing the module's reader and any worker +// blocked in the pipe write with err. Takes no lock: registerUploadChunk holds +// mu across that write, so locking here would deadlock. CloseWithError is safe +// concurrently with Write. +func (u *uploadState) close(err error) { + u.closeOnce.Do(func() { + if u.file != nil { + u.file.CloseWithError(err) + } + + close(u.done) + }) +} + +// downloadState represents an active download from agent to client (a file the +// module handed to SendFile). Chunks are read from reader and streamed out. +type downloadState struct { + transferID string + metadata *pb.FileMetadata + + // mu guards reader, closer, closed and chunkNumber. toClientWorker reads + // chunks while fromClientWorker (or the abort watchdog) may drop the + // transfer, and SendFile accepts any io.Reader — closing one concurrently + // with a Read is a data race for every implementation that is not *os.File. + mu sync.Mutex + reader io.Reader + closer io.Closer // optional closer for the reader (e.g. *os.File) + closed bool + chunkNumber int32 + offset int64 // bytes already sent; the next chunk's chunk_offset + + // done is closed once the transfer is released, freeing the context watcher. + done chan struct{} + + awaitingFinalAck bool // waiting for the client's TRANSFER_COMPLETE; guarded by downloadMutex +} + +// close releases the download's reader once. Callers must not hold +// downloadMutex: close waits for an in-flight Read to finish, and blocking the +// whole map behind one slow reader would stall unrelated transfers. +func (d *downloadState) close() { + d.mu.Lock() + defer d.mu.Unlock() + + if d.closed { + return + } + + d.closed = true + + if d.closer != nil { + d.closer.Close() + } + + close(d.done) +} // backend implements the module.Session interface. type backend struct { - printCh chan string - stdinCh chan []byte - stdoutCh chan []byte - stderrCh chan []byte - fileReqCh chan string - fileCh chan chan []byte // a single file is represented by a channel of bytes - - // mu guards currentFile, which is read and written from the module goroutine - // (SendFile) and from both broker workers, with no channel handing it between - // them — their ordering runs through the client round-trip, which is not a Go - // happens-before edge, so the field needs its own lock. - mu sync.Mutex - - // currentFile holds the name of the file currently being transferred. - // It names either the file the module requested from the client or the file - // being sent back to the client, since only one transfer is in flight at a time. - currentFile string + printCh chan string + stdinCh chan []byte + stdoutCh chan []byte + stderrCh chan []byte // log is the session-scoped logger, frozen in by the broker (see Broker.Start) // because the module.Session methods carry no context to derive it from. log *slog.Logger // done is closed when the broker's workers are torn down. The module-facing - // methods select on it so a call blocked on a session channel whose worker peer has - // exited unblocks — dropping output, or returning an error / io.EOF — instead - // of wedging the module goroutine for the process lifetime. A nil done (a - // backend built directly in a test) leaves the calls uncancellable. + // Print/Console calls select on it so a call blocked on a session channel whose + // worker peer has exited unblocks — dropping output — instead of wedging the + // module goroutine for the process lifetime. A nil done (a backend built + // directly in a test) leaves the calls uncancellable. done <-chan struct{} + + // File-transfer tracking. Uploads flow client → agent (RequestFile), downloads + // flow agent → client (SendFile). Both are chunked; a transfer is identified by + // a UUID and lives in the map until it completes or is aborted. + activeUploads map[string]*uploadState + activeDownloads map[string]*downloadState + uploadMutex sync.RWMutex + downloadMutex sync.RWMutex + + // fileTransferNotifyCh wakes toClientWorker when a new transfer is registered + // so it can announce it and start streaming. Buffered to one; a pending signal + // coalesces with a fresh one. + fileTransferNotifyCh chan struct{} + + // sendMu serializes all sends on the bidirectional stream. The connect + // BidiStream forbids concurrent Send calls, and both workers send responses + // (toClientWorker streams downloads; fromClientWorker acks uploads), so every + // send goes through sendToClient under this lock. + sendMu sync.Mutex + + // outputStopCh is closed once either done or shutdownCh fires. It is the stop + // signal handed to the Console writers, so module output is refused at the + // source during shutdown rather than accepted and then discarded by the + // worker — a write that returned success must not be silently dropped. + outputStopCh chan struct{} + + // Graceful-shutdown state. Shutdown marks the session done accepting module + // output while letting in-flight transfers finish; transferWg reaches zero + // once every registered transfer has been removed. + shutdownCh chan struct{} + shutdownMutex sync.Mutex + isShuttingDown bool + transferWg sync.WaitGroup } // logger returns the session's scoped logger, falling back to the default if @@ -62,29 +173,20 @@ func (s *backend) logger() *slog.Logger { return slog.Default() } -// currentFileName returns the name of the in-flight file transfer, or "" if none. -func (s *backend) currentFileName() string { - s.mu.Lock() - defer s.mu.Unlock() - - return s.currentFile -} - -// setCurrentFile records the name of the in-flight file transfer; "" clears it. -func (s *backend) setCurrentFile(name string) { - s.mu.Lock() - defer s.mu.Unlock() - - s.currentFile = name -} - // Print, Printf and Println forward a message to the client. The send is -// abandoned if the session has been torn down (done closed), so a module that -// keeps printing after its workers are gone is not wedged — the output is -// dropped, matching the fact that there is no longer a client to receive it. +// abandoned if the session has been torn down (done closed) or graceful shutdown +// has begun (shutdownCh closed), so a module that keeps printing after its +// workers are gone is not wedged — the output is dropped, matching the fact that +// there is no longer a client to receive it. +// +// Shutdown is checked here rather than in toClientWorker: once the worker has +// taken a message off printCh the Print call has already returned successfully, +// and discarding it at that point loses a line the module was told was +// delivered. Refusing it at the source keeps that contract. func (s *backend) Print(a ...any) { select { case s.printCh <- fmt.Sprint(a...): + case <-s.shutdownCh: case <-s.done: } } @@ -92,6 +194,7 @@ func (s *backend) Print(a ...any) { func (s *backend) Printf(format string, a ...any) { select { case s.printCh <- fmt.Sprintf(format, a...): + case <-s.shutdownCh: case <-s.done: } } @@ -99,6 +202,7 @@ func (s *backend) Printf(format string, a ...any) { func (s *backend) Println(a ...any) { select { case s.printCh <- fmt.Sprintln(a...): + case <-s.shutdownCh: case <-s.done: } } @@ -127,12 +231,14 @@ func (s *backend) Console() (stdin io.Reader, stdout, stderr io.Writer) { panic(fmt.Sprintf("session.Console: stdin reader: %v", err)) } - stdoutWriter, err = chanio.NewChanWriter(s.stdoutCh, s.done) + // The writers stop on outputStopCh, not done: shutdown must refuse new console + // output at the source, for the same reason Print does. + stdoutWriter, err = chanio.NewChanWriter(s.stdoutCh, s.outputStopCh) if err != nil { panic(fmt.Sprintf("session.Console: stdout writer: %v", err)) } - stderrWriter, err = chanio.NewChanWriter(s.stderrCh, s.done) + stderrWriter, err = chanio.NewChanWriter(s.stderrCh, s.outputStopCh) if err != nil { panic(fmt.Sprintf("session.Console: stderr writer: %v", err)) } @@ -140,80 +246,427 @@ func (s *backend) Console() (stdin io.Reader, stdout, stderr io.Writer) { return stdinReader, stdoutWriter, stderrWriter } -// RequestFile asks the client for the named file and returns a reader over its -// contents. It blocks until the client responds. The returned error is opaque -// (reported to the module as-is): it means the session was not initialized or the -// file stream could not be adapted, and is not meant to be matched. -func (s *backend) RequestFile(name string) (io.Reader, error) { - if s.fileReqCh == nil { - return nil, errors.New("session not initialized: file request channel is nil") +// RequestFile asks the client for the named file and returns a reader that +// streams its contents as chunks arrive. It returns immediately; the returned +// reader blocks until chunks are delivered (or the transfer is aborted). +// +// ctx bounds the transfer: cancelling it aborts the transfer and fails the +// reader. The session imposes no limit of its own. +// +// It returns ErrSessionShuttingDown or ErrSessionClosed if the session can no +// longer carry a transfer; both are terminal, so a module should return the +// error rather than retry. +func (s *backend) RequestFile(ctx context.Context, name string) (io.Reader, error) { + err := ctx.Err() + if err != nil { + return nil, err } + transferID := uuid.New().String() + // Requesting and reading a file is the upstream (client → agent) flow. - uplog := log.Scope(s.logger(), scopeSessionUpstream) - uplog.Debug("module requested file", "name", name) + log.Scope(s.logger(), scopeSessionUpstream).Debug("module requested file", "name", name, "transfer_id", transferID) + + reader, writer := io.Pipe() + state := &uploadState{ + transferID: transferID, + metadata: &pb.FileMetadata{ + Path: name, + Name: name, + Size: 0, // unknown; the client has the file + }, + file: writer, + reader: reader, + lastChunk: -1, + done: make(chan struct{}), + } - // Send the file request to the client, then wait for the file. Both block on - // a worker peer, so guard them with done: if the session is torn down first, - // return rather than wedge the module goroutine. - select { - case s.fileReqCh <- name: - case <-s.done: - return nil, fmt.Errorf("request file %q: %w", name, errSessionClosed) + err = s.register(func() { + s.uploadMutex.Lock() + s.activeUploads[transferID] = state + s.uploadMutex.Unlock() + }) + if err != nil { + writer.CloseWithError(err) + + return nil, err + } + + s.watchTransferCtx(ctx, state.done, func() { + s.logger().Debug("upload aborted by context", "transfer_id", transferID, "err", ctx.Err()) + s.removeUpload(transferID) + }) + + s.notifyFileTransfer() + + return state.reader, nil +} + +// watchTransferCtx aborts a transfer when ctx is cancelled. Stops on whichever +// comes first, so it never outlives the transfer it guards. +func (s *backend) watchTransferCtx(ctx context.Context, done <-chan struct{}, abort func()) { + if ctx.Done() == nil { + return } - var file chan []byte + go func() { + select { + case <-ctx.Done(): + abort() + case <-done: + } + }() +} + +// register runs add — which inserts a transfer into its map — and accounts for +// it on transferWg, but only while the session can still carry the transfer. +// The check and the accounting happen under shutdownMutex so Shutdown cannot +// land between them and strand a counted transfer that will never be announced. +// +// Lock order is shutdownMutex → upload/downloadMutex. Nothing acquires them the +// other way round: processFileTransfers releases shutdownMutex (via +// IsShuttingDown) before taking the map locks. +func (s *backend) register(add func()) error { + s.shutdownMutex.Lock() + defer s.shutdownMutex.Unlock() + + if s.isShuttingDown { + return ErrSessionShuttingDown + } + // A nil done channel (a backend built directly in a test) is never ready, + // leaving registration unrestricted. select { - case file = <-s.fileCh: case <-s.done: - return nil, fmt.Errorf("request file %q: %w", name, errSessionClosed) + return ErrSessionClosed + default: } - // The received channel is fed and closed by fromClientWorker right after the - // rendezvous, so this read always terminates: pass a nil done. - r, err := chanio.NewChanReader(file, nil, uplog) + add() + + s.transferWg.Add(1) + + return nil +} + +// SendFile streams r to the client under the given name. size is the total file +// size in bytes. It returns immediately; the download proceeds chunk by chunk in +// toClientWorker. If r implements io.Closer, the session takes ownership and +// closes it when the transfer completes — but only on a nil return; on error the +// reader is closed here and ownership stays with the caller. +// +// ctx bounds the transfer, and still does after SendFile returns: cancelling it +// aborts the in-flight transfer and closes r. The module is not notified. +// +// It returns ErrSessionShuttingDown or ErrSessionClosed if the session can no +// longer carry a transfer; both are terminal, so a module should return the +// error rather than retry. +func (s *backend) SendFile(ctx context.Context, name string, size int64, r io.Reader) error { + err := ctx.Err() if err != nil { - return nil, fmt.Errorf("request file %q: %w", name, err) + return err } - return r, nil -} + transferID := uuid.New().String() -// SendFile streams r to the client under the given name. It returns an error if a -// file transfer is already in progress or if reading r fails. The error is opaque -// (reported to the module as-is) and not meant to be matched. -func (s *backend) SendFile(name string, r io.Reader) error { - if s.currentFileName() != "" { - return fmt.Errorf("send file %q: a file request is already in progress", name) + // Sending a file to the client is the downstream (agent → client) flow. + log.Scope(s.logger(), scopeSessionDownstream).Debug("module sending file", "name", name, "size", size, "transfer_id", transferID) + + state := &downloadState{ + transferID: transferID, + metadata: &pb.FileMetadata{ + Path: name, + Name: name, + Size: size, + }, + reader: r, + chunkNumber: 0, + done: make(chan struct{}), } - content, err := io.ReadAll(r) + if c, ok := r.(io.Closer); ok { + state.closer = c + } + + err = s.register(func() { + s.downloadMutex.Lock() + s.activeDownloads[transferID] = state + s.downloadMutex.Unlock() + }) if err != nil { - return fmt.Errorf("send file %q: read source: %w", name, err) + // Registration failed, so no transfer owns r; close it here to honour the + // documented hand-off (ownership transfers only on a nil return). + state.close() + + return err } - // Sending a file to the client is the downstream (agent → client) flow. - downlog := log.Scope(s.logger(), scopeSessionDownstream) - downlog.Debug("module sending file", "name", name, "bytes", len(content)) + s.watchTransferCtx(ctx, state.done, func() { + s.logger().Debug("download aborted by context", "transfer_id", transferID, "err", ctx.Err()) + s.removeDownload(transferID) + }) - s.setCurrentFile(name) + s.notifyFileTransfer() - file := make(chan []byte, 1) + return nil +} - // Hand the file to toClientWorker. Guard the send with done: if the session - // is torn down first, return rather than wedge the module goroutine. The - // buffered content send and close below never block once the rendezvous - // succeeds. +// notifyFileTransfer signals toClientWorker that file-transfer work is available. +func (s *backend) notifyFileTransfer() { select { - case s.fileCh <- file: - case <-s.done: - return fmt.Errorf("send file %q: %w", name, errSessionClosed) + case s.fileTransferNotifyCh <- struct{}{}: + default: + } +} + +// getNextChunk returns the next chunk for a download transfer, a flag for whether +// it is the final chunk, and any read error. +func (s *backend) getNextChunk(transferID string) (*pb.FileChunk, bool, error) { + s.downloadMutex.RLock() + state, exists := s.activeDownloads[transferID] + s.downloadMutex.RUnlock() + + if !exists { + return nil, false, fmt.Errorf("download not found: %s", transferID) + } + + state.mu.Lock() + defer state.mu.Unlock() + + if state.closed { + return nil, false, fmt.Errorf("download closed: %s", transferID) + } + + chunkData := make([]byte, chunkSize) + bytesRead, err := state.reader.Read(chunkData) + chunkData = chunkData[:bytesRead] + + // A reader may return data together with io.EOF, so check n before the error. + // errors.Is, not ==: SendFile takes any io.Reader, and a wrapped EOF is a + // clean end of file, not a read failure. + isFinal := errors.Is(err, io.EOF) + if err != nil && !isFinal { + return nil, false, err + } + + chunk := &pb.FileChunk{ + TransferId: transferID, + ChunkNumber: state.chunkNumber, + ChunkData: chunkData, + ChunkOffset: state.offset, + IsFinal: isFinal, + } + + state.chunkNumber++ + // Track the real offset rather than chunkNumber*chunkSize: a short read is + // legal and leaves chunks smaller than the maximum. + state.offset += int64(bytesRead) + + return chunk, isFinal, nil +} + +// registerUploadChunk writes a chunk received from the client into the upload's +// pipe, validating chunk ordering. The final chunk closes the pipe. +func (s *backend) registerUploadChunk(transferID string, chunk *pb.FileChunk) error { + s.uploadMutex.RLock() + state, exists := s.activeUploads[transferID] + s.uploadMutex.RUnlock() + + if !exists { + return fmt.Errorf("upload not found: %s", transferID) + } + + state.mu.Lock() + defer state.mu.Unlock() + + expectedChunk := state.lastChunk + 1 + if chunk.GetChunkNumber() != expectedChunk { + // A gap or a repeat means the client is not speaking the protocol; there is + // no resend path, so the transfer cannot recover. Wrapping ErrBadFileTransfer + // lets the RPC layer report CodeInvalidArgument instead of a vague internal + // fault (see brokerError in cmds/dutagent). + return fmt.Errorf("%w: chunk order violation: expected %d, got %d", + ErrBadFileTransfer, expectedChunk, chunk.GetChunkNumber()) } - file <- content + state.lastChunk = chunk.GetChunkNumber() - close(file) // indicate EOF. + _, err := state.file.Write(chunk.GetChunkData()) + if err != nil { + return err + } + + if chunk.GetIsFinal() { + state.file.Close() + } return nil } + +// getActiveUploads returns the IDs of every active upload transfer. +func (s *backend) getActiveUploads() []string { + s.uploadMutex.RLock() + defer s.uploadMutex.RUnlock() + + transferIDs := make([]string, 0, len(s.activeUploads)) + for id := range s.activeUploads { + transferIDs = append(transferIDs, id) + } + + return transferIDs +} + +// getActiveDownloads returns the IDs of every active download transfer. +func (s *backend) getActiveDownloads() []string { + s.downloadMutex.RLock() + defer s.downloadMutex.RUnlock() + + transferIDs := make([]string, 0, len(s.activeDownloads)) + for id := range s.activeDownloads { + transferIDs = append(transferIDs, id) + } + + return transferIDs +} + +// removeDownload removes a completed download from tracking, closing its reader +// if it owns one. Idempotent. +func (s *backend) removeDownload(transferID string) { + s.downloadMutex.Lock() + state, exists := s.activeDownloads[transferID] + + if exists { + delete(s.activeDownloads, transferID) + } + + s.downloadMutex.Unlock() + + if !exists { + return + } + + state.close() + + s.transferWg.Done() +} + +// getUpload retrieves upload state for a transfer ID, or nil. +func (s *backend) getUpload(transferID string) *uploadState { + s.uploadMutex.RLock() + defer s.uploadMutex.RUnlock() + + return s.activeUploads[transferID] +} + +// removeUpload removes an upload from tracking, closing its pipe. Idempotent. +func (s *backend) removeUpload(transferID string) { + s.uploadMutex.Lock() + defer s.uploadMutex.Unlock() + + state, exists := s.activeUploads[transferID] + if !exists { + return + } + + state.close(errors.New("transfer removed")) + + delete(s.activeUploads, transferID) + + s.transferWg.Done() +} + +// getDownload retrieves download state for a transfer ID, or nil. +func (s *backend) getDownload(transferID string) *downloadState { + s.downloadMutex.RLock() + defer s.downloadMutex.RUnlock() + + return s.activeDownloads[transferID] +} + +// isDownloadAwaitingAck reports whether a download is waiting for the client's +// TRANSFER_COMPLETE. +func (s *backend) isDownloadAwaitingAck(transferID string) bool { + s.downloadMutex.RLock() + defer s.downloadMutex.RUnlock() + + if state, exists := s.activeDownloads[transferID]; exists { + return state.awaitingFinalAck + } + + return false +} + +// markDownloadAwaitingAck marks a download as waiting for the client's +// TRANSFER_COMPLETE. +func (s *backend) markDownloadAwaitingAck(transferID string) { + s.downloadMutex.Lock() + defer s.downloadMutex.Unlock() + + if state, exists := s.activeDownloads[transferID]; exists { + state.awaitingFinalAck = true + } +} + +// IsShuttingDown reports whether the session is in graceful shutdown. +func (s *backend) IsShuttingDown() bool { + s.shutdownMutex.Lock() + defer s.shutdownMutex.Unlock() + + return s.isShuttingDown +} + +// Shutdown begins graceful shutdown: module output stops being forwarded, while +// the workers keep processing in-flight file transfers until they complete. +func (s *backend) Shutdown() { + s.shutdownMutex.Lock() + defer s.shutdownMutex.Unlock() + + if s.isShuttingDown { + return + } + + s.logger().Debug("session initiating graceful shutdown") + + s.isShuttingDown = true + close(s.shutdownCh) +} + +// WaitForTransfers blocks until every active transfer has completed. +func (s *backend) WaitForTransfers() { + s.transferWg.Wait() +} + +// abortTransfers tears down every still-active transfer and balances the +// transfer wait group. It is called once the stream workers have exited (e.g. +// the client disconnected mid-transfer) so a half-finished transfer cannot wedge +// WaitForTransfers — and therefore graceful shutdown — forever. +func (s *backend) abortTransfers() { + s.uploadMutex.Lock() + + for id, state := range s.activeUploads { + state.close(errors.New("transfer aborted: stream closed")) + + delete(s.activeUploads, id) + s.transferWg.Done() + } + + s.uploadMutex.Unlock() + + // Detach the downloads under the map lock, then close them outside it: close + // waits for an in-flight Read, and uploads must not be held up behind that. + s.downloadMutex.Lock() + + downloads := make([]*downloadState, 0, len(s.activeDownloads)) + for id, state := range s.activeDownloads { + downloads = append(downloads, state) + + delete(s.activeDownloads, id) + } + + s.downloadMutex.Unlock() + + for _, state := range downloads { + state.close() + s.transferWg.Done() + } +} diff --git a/internal/dutagent/session/worker.go b/internal/dutagent/session/worker.go index 22286227..ba757f0a 100644 --- a/internal/dutagent/session/worker.go +++ b/internal/dutagent/session/worker.go @@ -9,8 +9,8 @@ import ( "errors" "fmt" "io" + "log/slog" - "github.com/BlindspotSoftware/dutctl/internal/chanio" "github.com/BlindspotSoftware/dutctl/internal/log" pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" @@ -21,34 +21,329 @@ import ( // treating it as an internal fault. var ErrBadFileTransfer = errors.New("bad file transfer") -// toClientWorker sends messages from the module session to the client. -// It loops until ctx is cancelled (returning nil) or a stream send fails -// (returning that error). +// ErrStreamClosed reports that the stream could not accept a message because it +// is already gone. It is not a run failure: the workers stop quietly on it, +// since there is no client left to tell. Any other send error is real and +// terminates the worker with that error. +var ErrStreamClosed = errors.New("stream closed") + +// sendToClient serializes all sends on the bidirectional stream. The connect +// BidiStream is not safe for concurrent Send calls, and both workers send +// responses (toClientWorker streams downloads and module output; +// fromClientWorker acks uploads), so every send goes through this lock. +// +// A Send on an already-closed stream can panic. That is recovered into +// ErrStreamClosed rather than a nil error: reporting success for a message that +// was never sent leaves the caller believing a chunk landed, and leaves this +// worker looping against a dead stream until something else cancels it. +func (s *backend) sendToClient(stream Stream, res *pb.RunResponse) (err error) { + s.sendMu.Lock() + defer s.sendMu.Unlock() + + defer func() { + if r := recover(); r != nil { + s.logger().Warn("recovered from panic in stream.Send", "err", r) + + err = fmt.Errorf("%w: send panicked: %v", ErrStreamClosed, r) + } + }() + + return stream.Send(res) +} + +// sendOrStop delivers res and classifies a failure for a worker loop: a closed +// stream stops the worker without failing the run (there is no client left to +// report to), any other error is terminal and surfaces on the error channel. +func (s *backend) sendOrStop(stream Stream, res *pb.RunResponse) (bool, error) { + sendErr := s.sendToClient(stream, res) + + switch { + case sendErr == nil: + return false, nil + case errors.Is(sendErr, ErrStreamClosed): + return true, nil + default: + return true, sendErr + } +} + +// sendDownloadError reports a download read error to the client and drops the +// transfer. The read error itself is not fatal to the run — only a failure to +// deliver the report is, and that is returned. +func sendDownloadError( + stream Stream, + s *backend, + transferID string, + downloadMetadataSent map[string]bool, + err error, +) error { + s.logger().Warn("error getting chunk for download", "transfer_id", transferID, "err", err) + + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_ERROR, + ErrorMessage: fmt.Sprintf("error reading file: %v", err), + NextChunkExpected: 0, + }, + }, + } + + sendErr := s.sendToClient(stream, res) + + s.removeDownload(transferID) + delete(downloadMetadataSent, transferID) + + return sendErr +} + +// sendDownloadMetadata announces a download to the client (its metadata and +// direction). It reports whether a message was sent, and returns a send error +// unchanged for the caller to act on. +func sendDownloadMetadata( + stream Stream, + s *backend, + transferID string, + downloadMetadataSent map[string]bool, +) (bool, error) { + if downloadMetadataSent[transferID] { + return false, nil + } + + download := s.getDownload(transferID) + if download == nil { + return false, nil + } + + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferRequest{ + FileTransferRequest: &pb.FileTransferRequest{ + TransferId: transferID, + Metadata: download.metadata, + Direction: pb.FileTransferRequest_DIRECTION_DOWNLOAD, + }, + }, + } + + sendErr := s.sendToClient(stream, res) + if sendErr != nil { + return false, sendErr + } + + downloadMetadataSent[transferID] = true + + return true, nil +} + +// handleDownloadFileTransfer advances a single download: announce its metadata, +// then stream one chunk. It reports whether a message was sent. +// +// A send error is returned rather than logged and shrugged off. The chunk has +// already been consumed from the module's reader by then, so there is nothing to +// retry; carrying on would silently drop those bytes and leave the transfer +// stalled with no further wake-up and nothing on the error channel. +func handleDownloadFileTransfer( + stream Stream, + s *backend, + transferID string, + downloadMetadataSent map[string]bool, +) (bool, error) { + // Skip while waiting for the client's acknowledgment of the final chunk. + if s.isDownloadAwaitingAck(transferID) { + return false, nil + } + + // Announce the transfer before streaming any chunk. + if !downloadMetadataSent[transferID] { + return sendDownloadMetadata(stream, s, transferID, downloadMetadataSent) + } + + chunk, isFinal, err := s.getNextChunk(transferID) + if err != nil { + return true, sendDownloadError(stream, s, transferID, downloadMetadataSent, err) + } + + if chunk == nil { + return false, nil + } + + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileChunk{FileChunk: chunk}, + } + + sendErr := s.sendToClient(stream, res) + if sendErr != nil { + return false, sendErr + } + + if isFinal { + s.markDownloadAwaitingAck(transferID) + } + + return true, nil +} + +// forgetFinishedDownloads drops announcement markers for transfers that are no +// longer active, so the map cannot grow for the life of a long session. +func forgetFinishedDownloads(s *backend, downloadMetadataSent map[string]bool) { + if len(downloadMetadataSent) == 0 { + return + } + + active := make(map[string]struct{}, len(downloadMetadataSent)) + for _, id := range s.getActiveDownloads() { + active[id] = struct{}{} + } + + for id := range downloadMetadataSent { + if _, ok := active[id]; !ok { + delete(downloadMetadataSent, id) + } + } +} + +// processFileTransfers announces one pending upload and advances one download per +// call (one at a time for fairness). It reports whether a message was sent, so +// the caller can re-signal for the remaining work, and returns any send error so +// the worker can terminate instead of leaving the transfer stalled. +func processFileTransfers(stream Stream, s *backend, downloadMetadataSent map[string]bool) (bool, error) { + sent := false + + // Announce a FileTransferRequest for a new upload that has not been sent yet. + if !s.IsShuttingDown() { + for _, transferID := range s.getActiveUploads() { + upload := s.getUpload(transferID) + if upload == nil { + continue + } + + // metadata is written by fromClientWorker under upload.mu, so read it + // (and requestSent) under the same lock. + upload.mu.Lock() + metadata := upload.metadata + alreadySent := upload.requestSent + upload.mu.Unlock() + + if metadata == nil || alreadySent { + continue + } + + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferRequest{ + FileTransferRequest: &pb.FileTransferRequest{ + TransferId: transferID, + Metadata: metadata, + Direction: pb.FileTransferRequest_DIRECTION_UPLOAD, + }, + }, + } + + sendErr := s.sendToClient(stream, res) + if sendErr != nil { + return sent, sendErr + } + + upload.mu.Lock() + upload.requestSent = true + upload.mu.Unlock() + + sent = true + + break // one at a time + } + } + + // Advance the first download that has work available. + for _, transferID := range s.getActiveDownloads() { + advanced, err := handleDownloadFileTransfer(stream, s, transferID, downloadMetadataSent) + if err != nil { + return sent, err + } + + if advanced { + sent = true + + break // one at a time for fairness + } + } + + return sent, nil +} + +// advanceFileTransfers runs one file-transfer step and classifies the outcome +// for toClientWorker's loop, reporting whether the worker should stop. +func advanceFileTransfers( + l *slog.Logger, + stream Stream, + s *backend, + downloadMetadataSent map[string]bool, +) (bool, error) { + forgetFinishedDownloads(s, downloadMetadataSent) + + sent, err := processFileTransfers(stream, s, downloadMetadataSent) + if err != nil { + // A dead stream is not a run failure — there is no client left to tell — + // but this worker must stop either way. + if errors.Is(err, ErrStreamClosed) { + l.Debug("worker terminating", "reason", "stream closed") + + return true, nil + } + + l.Warn("error advancing file transfer", "err", err) + + return true, err + } + + if sent { + // More work may be pending; re-signal. + s.notifyFileTransfer() + } + + return false, nil +} + +// toClientWorker sends module output and download chunks to the client. It loops +// until ctx is cancelled (returning nil) or a stream send fails (returning that +// error). While the session is shutting down, module output is discarded but file +// transfers keep flowing until they complete. // -//nolint:cyclop, funlen +//nolint:cyclop // main select loop inherently has multiple cases func toClientWorker(ctx context.Context, stream Stream, s *backend) error { l := log.FromContext(ctx) + // Track which downloads have had their metadata announced. + downloadMetadataSent := make(map[string]bool) + for { select { case <-ctx.Done(): return nil case str := <-s.printCh: + // No shutdown check: Print refuses new output once shutdown starts, so + // anything already on the channel was accepted and must still be sent. res := &pb.RunResponse{ Msg: &pb.RunResponse_Print{Print: &pb.Print{Text: []byte(str)}}, } - err := stream.Send(res) - if err != nil { + stop, err := s.sendOrStop(stream, res) + if stop { + if err != nil { + l.Warn("error sending print", "err", err) + } + return err } case bytes := <-s.stdoutCh: + // As with printCh: the Console writers refuse output once shutdown + // starts, so whatever reached this channel was accepted and is sent. res := &pb.RunResponse{ Msg: &pb.RunResponse_Console{Console: &pb.Console{Data: &pb.Console_Stdout{Stdout: bytes}}}, } - err := stream.Send(res) - if err != nil { + stop, err := s.sendOrStop(stream, res) + if stop { return err } case bytes := <-s.stderrCh: @@ -56,66 +351,26 @@ func toClientWorker(ctx context.Context, stream Stream, s *backend) error { Msg: &pb.RunResponse_Console{Console: &pb.Console{Data: &pb.Console_Stderr{Stderr: bytes}}}, } - err := stream.Send(res) - if err != nil { - return err - } - case name := <-s.fileReqCh: - // Record the in-flight file before sending the request: the client's - // response is driven by this Send, so setting currentFile afterwards - // could race a fast response that fromClientWorker validates against - // currentFile (see the currentFile guards there). - s.setCurrentFile(name) - - res := &pb.RunResponse{ - Msg: &pb.RunResponse_FileRequest{FileRequest: &pb.FileRequest{Path: name}}, - } - - err := stream.Send(res) - if err != nil { - return err - } - case file := <-s.fileCh: - // The channel is fed and closed by SendFile right after the - // rendezvous, so this read always terminates: pass a nil done. - r, err := chanio.NewChanReader(file, nil, l) - if err != nil { + stop, err := s.sendOrStop(stream, res) + if stop { return err } - - content, err := io.ReadAll(r) - if err != nil { + case <-s.fileTransferNotifyCh: + stop, err := advanceFileTransfers(l, stream, s, downloadMetadataSent) + if stop { return err } - - name := s.currentFileName() - - l.Debug("file received from module", "name", name, "bytes", len(content)) - - res := &pb.RunResponse{ - Msg: &pb.RunResponse_File{ - File: &pb.File{ - Path: name, - Content: content, - }, - }, - } - - err = stream.Send(res) - if err != nil { - return err - } - - s.setCurrentFile("") } } } -// fromClientWorker reads messages from the client and passes them to the module session. -// It loops until ctx is cancelled or the client closes the stream with io.EOF (both -// returning nil), or a stream/protocol error occurs (returning that error). +// fromClientWorker reads messages from the client and routes them: console stdin +// to the module, and file-transfer messages (chunks, requests, acknowledgments) +// to the transfer state machine. It loops until ctx is cancelled or the client +// closes the stream with io.EOF (both returning nil), or a stream error occurs +// (returning that error). // -//nolint:cyclop,funlen,gocognit +//nolint:cyclop,funlen,gocognit,gocyclo,maintidx func fromClientWorker(ctx context.Context, stream Stream, s *backend) error { l := log.FromContext(ctx) @@ -207,53 +462,182 @@ func fromClientWorker(ctx context.Context, stream Stream, s *backend) error { return nil case s.stdinCh <- stdin: } - default: l.Warn("unexpected console message", "type", fmt.Sprintf("%T", consoleMsg)) } - case *pb.RunRequest_File: - fileMsg := msg.File - if fileMsg == nil { - return fmt.Errorf("%w: received empty file-message", ErrBadFileTransfer) + + case *pb.RunRequest_FileChunk: + chunk := msg.FileChunk + if chunk == nil { + continue } - want := s.currentFileName() - if want == "" { - return fmt.Errorf("%w: received file-message without a former request", ErrBadFileTransfer) + transferID := chunk.GetTransferId() + + registerErr := s.registerUploadChunk(transferID, chunk) + if registerErr != nil { + l.Warn("error registering upload chunk", "transfer_id", transferID, "err", registerErr) + + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_ERROR, + ErrorMessage: fmt.Sprintf("error processing chunk: %v", registerErr), + }, + }, + } + + stop, sendErr := s.sendOrStop(stream, res) + if stop { + return sendErr + } + + s.removeUpload(transferID) + + // An unknown transfer ID is a benign race: the agent may have + // dropped the upload while chunks were still in flight. A broken + // chunk sequence is not — it is a protocol violation the client + // cannot recover from, so it terminates the run. + if errors.Is(registerErr, ErrBadFileTransfer) { + return registerErr + } + + continue } - path := fileMsg.GetPath() - content := fileMsg.GetContent() + // Acknowledge the chunk. + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_CHUNK_RECEIVED, + NextChunkExpected: chunk.GetChunkNumber() + 1, + }, + }, + } + + stop, sendErr := s.sendOrStop(stream, res) + if stop { + if sendErr != nil { + l.Warn("error sending chunk acknowledgment", "transfer_id", transferID, "err", sendErr) + } - if content == nil { - return fmt.Errorf("%w: received file-message without content", ErrBadFileTransfer) + return sendErr } - if path != want { - return fmt.Errorf("%w: received file-message %q but requested %q", ErrBadFileTransfer, path, want) + // The final chunk completes the upload. + if chunk.GetIsFinal() { + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE, + }, + }, + } + + stop, sendErr := s.sendOrStop(stream, res) + if stop { + if sendErr != nil { + l.Warn("error sending transfer complete", "transfer_id", transferID, "err", sendErr) + } + + return sendErr + } + + s.removeUpload(transferID) } - l.Debug("received file from client", "name", path, "bytes", len(content)) + case *pb.RunRequest_FileTransferRequest: + ftReq := msg.FileTransferRequest + if ftReq == nil { + continue + } - file := make(chan []byte, 1) + transferID := ftReq.GetTransferId() + metadata := ftReq.GetMetadata() + + // Only uploads the module has requested are known here. + upload := s.getUpload(transferID) + if upload == nil { + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_TRANSFER_REJECTED, + ErrorMessage: "no matching request from module", + }, + }, + } - // Hand the file to the module's RequestFile. Unlike the stdin - // send above, the receiver is the module goroutine, which may - // already be gone on teardown; guard the send with ctx.Done so an - // abandoned transfer cannot wedge this worker (and, through - // wg.Wait, the broker) forever. The buffered content send and - // close below never block once the rendezvous succeeds. - select { - case s.fileCh <- file: - case <-ctx.Done(): - return nil + stop, sendErr := s.sendOrStop(stream, res) + if stop { + return sendErr + } + + continue + } + + // Record the client's metadata (size/name), then accept. + upload.mu.Lock() + upload.metadata = metadata + upload.mu.Unlock() + + res := &pb.RunResponse{ + Msg: &pb.RunResponse_FileTransferResponse{ + FileTransferResponse: &pb.FileTransferResponse{ + TransferId: transferID, + Status: pb.FileTransferResponse_STATUS_ACCEPTED, + }, + }, } - file <- content + stop, sendErr := s.sendOrStop(stream, res) + if stop { + if sendErr != nil { + l.Warn("error sending acceptance", "transfer_id", transferID, "err", sendErr) + } + + return sendErr + } + + case *pb.RunRequest_FileTransferResponse: + ftRes := msg.FileTransferResponse + if ftRes == nil { + continue + } + + transferID := ftRes.GetTransferId() + + switch ftRes.GetStatus() { + case pb.FileTransferResponse_STATUS_ERROR: + s.removeDownload(transferID) + s.removeUpload(transferID) + case pb.FileTransferResponse_STATUS_TRANSFER_COMPLETE: + if s.isDownloadAwaitingAck(transferID) { + s.removeDownload(transferID) + } + case pb.FileTransferResponse_STATUS_TRANSFER_REJECTED: + // A rejection can answer either direction: the client refuses to + // read a file it was asked to upload, or to write a download whose + // destination is not among the command arguments. Drop both, or the + // agent keeps streaming a file the client has already refused. + l.Warn("client rejected transfer", + "transfer_id", transferID, "reason", ftRes.GetErrorMessage()) + + s.removeUpload(transferID) + s.removeDownload(transferID) + case pb.FileTransferResponse_STATUS_UNSPECIFIED, + pb.FileTransferResponse_STATUS_ACCEPTED, + pb.FileTransferResponse_STATUS_CHUNK_RECEIVED: + // Not meaningful from the client for these flows; ignore. + } - close(file) + case *pb.RunRequest_Command: + // Command starts a run and is handled by the RPC entrypoint, not + // here; it should not arrive during an active session. - s.setCurrentFile("") default: l.Warn("unexpected message type", "type", fmt.Sprintf("%T", msg)) } diff --git a/internal/test/mock/session.go b/internal/test/mock/session.go index 98598cb8..943c8e4d 100644 --- a/internal/test/mock/session.go +++ b/internal/test/mock/session.go @@ -6,6 +6,7 @@ package mock import ( + "context" "fmt" "io" @@ -79,7 +80,7 @@ func (m *Session) Console() (stdin io.Reader, stdout, stderr io.Writer) { // RequestFile records the call in RequestFileCalled and the argument in RequestedFileName. // It returns RequestFileErr if that field is set; otherwise it returns RequestedFileResponse, // panicking if that field is unset. -func (m *Session) RequestFile(name string) (io.Reader, error) { +func (m *Session) RequestFile(_ context.Context, name string) (io.Reader, error) { m.RequestFileCalled = true m.RequestedFileName = name @@ -96,7 +97,7 @@ func (m *Session) RequestFile(name string) (io.Reader, error) { // SendFile records the call in SendFileCalled and the argument in SentFileName, then reads // all of r into SentFileContent. It returns any error from reading r without recording content. -func (m *Session) SendFile(name string, r io.Reader) error { +func (m *Session) SendFile(_ context.Context, name string, _ int64, r io.Reader) error { m.SendFileCalled = true m.SentFileName = name diff --git a/pkg/module/dummy/dummy_file_transfer.go b/pkg/module/dummy/dummy_file_transfer.go index b21c1f32..1cb13173 100644 --- a/pkg/module/dummy/dummy_file_transfer.go +++ b/pkg/module/dummy/dummy_file_transfer.go @@ -63,7 +63,7 @@ func (d *FT) Run(ctx context.Context, s module.Session, args ...string) error { inFile := args[0] s.Printf("Requesting file %q passed in arg[0] as input\n", inFile) - fileReader, err := s.RequestFile(inFile) + fileReader, err := s.RequestFile(ctx, inFile) if err != nil { return fmt.Errorf("file request failed: %v", err) } @@ -87,7 +87,7 @@ func (d *FT) Run(ctx context.Context, s module.Session, args ...string) error { outFile := args[1] - err = s.SendFile(outFile, bytes.NewBuffer(result)) + err = s.SendFile(ctx, outFile, int64(len(result)), bytes.NewBuffer(result)) if err != nil { return fmt.Errorf("failed to send file: %v", err) } diff --git a/pkg/module/file/file.go b/pkg/module/file/file.go index 47b8e075..02b7f57e 100644 --- a/pkg/module/file/file.go +++ b/pkg/module/file/file.go @@ -207,7 +207,7 @@ func (f *File) uploadFile(ctx context.Context, sesh module.Session) error { l.Debug(fmt.Sprintf("uploading %q from client to %q on dutagent", f.sourcePath, f.destPath)) // Request file from client - fileReader, err := sesh.RequestFile(f.sourcePath) + fileReader, err := sesh.RequestFile(ctx, f.sourcePath) if err != nil { return fmt.Errorf("failed to request file from client: %w", err) } @@ -269,16 +269,18 @@ func (f *File) downloadFile(ctx context.Context, sesh module.Session) error { return fmt.Errorf("failed to open source file %q: %w", f.sourcePath, err) } - // Open source file srcFile, err := os.Open(f.sourcePath) if err != nil { return fmt.Errorf("failed to open source file %q: %w", f.sourcePath, err) } - defer srcFile.Close() - // Send file to client - err = sesh.SendFile(f.destPath, srcFile) + // SendFile takes ownership of the file on success — the transfer is chunked + // and proceeds after this returns, so the session closes it and we must NOT + // defer Close. On error ownership stays here. + err = sesh.SendFile(ctx, f.destPath, fileInfo.Size(), srcFile) if err != nil { + srcFile.Close() + return fmt.Errorf("failed to send file to client: %w", err) } diff --git a/pkg/module/flash-emulate/flash-emulate.go b/pkg/module/flash-emulate/flash-emulate.go index 3ec83a67..527f8be3 100644 --- a/pkg/module/flash-emulate/flash-emulate.go +++ b/pkg/module/flash-emulate/flash-emulate.go @@ -144,7 +144,7 @@ func (e *FlashEmulate) Run(ctx context.Context, sesh module.Session, args ...str e.localImagePath = tmpFile.Name() - err = uploadImage(sesh, e.clientImagePath, e.localImagePath) + err = uploadImage(ctx, sesh, e.clientImagePath, e.localImagePath) if err != nil { _ = os.RemoveAll(e.localImagePath) e.localImagePath = "" @@ -186,8 +186,8 @@ func (e *FlashEmulate) cmdline() []string { } // uploadImage receives the firmware image file from sesh and saves it locally. -func uploadImage(sesh module.Session, remote, local string) error { - img, err := sesh.RequestFile(remote) +func uploadImage(ctx context.Context, sesh module.Session, remote, local string) error { + img, err := sesh.RequestFile(ctx, remote) if err != nil { return fmt.Errorf("request image from client: %w", err) } diff --git a/pkg/module/flash/flash.go b/pkg/module/flash/flash.go index af23cc0a..b52b4bee 100644 --- a/pkg/module/flash/flash.go +++ b/pkg/module/flash/flash.go @@ -174,7 +174,7 @@ func (f *Flash) Run(ctx context.Context, sesh module.Session, args ...string) er f.localImagePath = localImagePath if f.op == opWrite { - err := uploadImage(sesh, f.clientImagePath, f.localImagePath) + err := uploadImage(ctx, sesh, f.clientImagePath, f.localImagePath) if err != nil { return err } @@ -202,7 +202,7 @@ func (f *Flash) Run(ctx context.Context, sesh module.Session, args ...string) er time.Sleep(1 * time.Second) if f.op == opRead { - err := downloadImage(sesh, f.localImagePath, f.clientImagePath) + err := downloadImage(ctx, sesh, f.localImagePath, f.clientImagePath) if err != nil { return err } @@ -278,8 +278,8 @@ func (f *Flash) cmdline() []string { } // uploadImage receives the flash image file from sesh and saves it locally. -func uploadImage(sesh module.Session, remote, local string) error { - img, err := sesh.RequestFile(remote) +func uploadImage(ctx context.Context, sesh module.Session, remote, local string) error { + img, err := sesh.RequestFile(ctx, remote) if err != nil { return fmt.Errorf("request flash image from client for write operation: %w", err) } @@ -288,6 +288,7 @@ func uploadImage(sesh module.Session, remote, local string) error { if err != nil { return fmt.Errorf("save flash image on dutagent for write operation: %w", err) } + defer imgFile.Close() _, err = io.Copy(imgFile, img) if err != nil { @@ -298,14 +299,26 @@ func uploadImage(sesh module.Session, remote, local string) error { } // downloadImage sends the local flash image file to sesh. -func downloadImage(sesh module.Session, local, remote string) error { +func downloadImage(ctx context.Context, sesh module.Session, local, remote string) error { file, err := os.Open(local) if err != nil { return fmt.Errorf("open flash image on dutagent after read operation: %w", err) } - err = sesh.SendFile(remote, file) + fileInfo, err := file.Stat() if err != nil { + file.Close() + + return fmt.Errorf("stat flash image on dutagent after read operation: %w", err) + } + + // SendFile takes ownership of the file on success — the transfer is chunked + // and finishes after this returns, so the session closes it, not us. On error + // ownership stays here. + err = sesh.SendFile(ctx, remote, fileInfo.Size(), file) + if err != nil { + file.Close() + return fmt.Errorf("send flash image to client after read operation: %w", err) } diff --git a/pkg/module/module.go b/pkg/module/module.go index ed7fb09d..b477852e 100644 --- a/pkg/module/module.go +++ b/pkg/module/module.go @@ -102,11 +102,25 @@ type Session interface { // It thus indicates to the client that the module may want to interact with the user // via standard input and output streams. Console() (stdin io.Reader, stdout, stderr io.Writer) - // RequestFile requests a file from the client. - // The file is identified by its name and is made available to the module via the returned io.Reader. - RequestFile(name string) (io.Reader, error) - // SendFile sends a file to the client. - SendFile(name string, r io.Reader) error + // RequestFile requests a file from the client. The file is identified by its + // name and is made available to the module via the returned io.Reader. + // + // ctx bounds the transfer: cancelling it aborts the transfer and fails the + // reader. Pass Run's context, or a deadline derived from it. The session + // imposes no limit of its own. + RequestFile(ctx context.Context, name string) (io.Reader, error) + // SendFile sends a file to the client. size is the total file size in bytes, + // used to drive chunked transfer and report progress. + // + // It returns as soon as the transfer is registered; the bytes are streamed + // afterwards, so r must stay readable beyond this call. If r implements + // io.Closer the session takes ownership and closes it when the transfer + // completes — but only on a nil return. On error the caller still owns r and + // must close it. + // + // ctx bounds the transfer, and still does after SendFile returns: cancelling + // it aborts the in-flight transfer and closes r. + SendFile(ctx context.Context, name string, size int64, r io.Reader) error } // Record holds the information required to register a module. diff --git a/protobuf/buf.yaml b/protobuf/buf.yaml index 4cb6a23e..0a714b4e 100644 --- a/protobuf/buf.yaml +++ b/protobuf/buf.yaml @@ -1,7 +1,7 @@ version: v2 lint: use: - - DEFAULT + - STANDARD breaking: use: - FILE diff --git a/protobuf/dutctl/v1/dutctl.proto b/protobuf/dutctl/v1/dutctl.proto index e0dbb712..defb31f0 100644 --- a/protobuf/dutctl/v1/dutctl.proto +++ b/protobuf/dutctl/v1/dutctl.proto @@ -65,21 +65,36 @@ message DetailsResponse { // to further interact with the agent during the command execution. // The first RunRequest message sent to a agent must always contain a Command message. message RunRequest { + // 3 and 4 carried the unchunked File/FileRequest messages. They are reserved + // rather than reused so a peer speaking the old protocol fails to parse + // instead of silently misreading a chunked transfer as a whole file. + reserved 3, 4; + reserved "file"; + oneof msg { Command command = 1; Console console = 2; - File file = 3; + FileTransferRequest file_transfer_request = 5; + FileChunk file_chunk = 6; + FileTransferResponse file_transfer_response = 7; } } // RunResponse is sent by the agent in response to a RunRequest and can either contain // just the output of the command (Print), or trigger further interaction with the client. message RunResponse { + // See RunRequest: 3 and 4 held the unchunked messages and stay reserved. The + // file-transfer fields share RunRequest's numbering, since both directions + // carry the same three messages. + reserved 3, 4; + reserved "file", "file_request"; + oneof msg { Print print = 1; Console console = 2; - FileRequest file_request = 3; - File file = 4; + FileTransferRequest file_transfer_request = 5; + FileChunk file_chunk = 6; + FileTransferResponse file_transfer_response = 7; } } @@ -105,15 +120,51 @@ message Console { } } -// FileRequest is used by the agent to request a file from the client. -message FileRequest { - string path = 1; +// FileMetadata describes the properties of a file being transferred. +message FileMetadata { + string path = 1; // Path of the file + int64 size = 2; // Total file size in bytes + string name = 3; // Filename for reference +} + +// FileChunk represents a single chunk of a file being transferred. +// Maximum chunk size is 1MB. +message FileChunk { + string transfer_id = 1; // Unique ID for this transfer session + int32 chunk_number = 2; // Sequential chunk number (0-indexed) + bytes chunk_data = 3; // File data chunk (max 1MB) + int64 chunk_offset = 4; // Byte offset in the file + bool is_final = 5; // Whether this is the last chunk } -// File is used by the client and the agent to transfer a file. -message File { - string path = 1; - bytes content = 2; +// FileTransferRequest initiates a file transfer. +// Sent by the side requesting to receive a file. +message FileTransferRequest { + string transfer_id = 1; // Unique ID for this transfer + FileMetadata metadata = 2; // File metadata + + enum Direction { + DIRECTION_UNSPECIFIED = 0; + DIRECTION_UPLOAD = 1; // Client sending file to agent (agent requesting from client) + DIRECTION_DOWNLOAD = 2; // Agent sending file to client + } + Direction direction = 3; // Direction of file transfer +} + +// FileTransferResponse acknowledges file transfer requests and chunk receipts. +message FileTransferResponse { + string transfer_id = 1; // Matches request ID + enum Status { + STATUS_UNSPECIFIED = 0; + STATUS_ACCEPTED = 1; // Ready to receive chunks + STATUS_CHUNK_RECEIVED = 2; // Chunk received successfully + STATUS_TRANSFER_COMPLETE = 3; // All chunks received + STATUS_TRANSFER_REJECTED = 4; // Transfer cannot proceed + STATUS_ERROR = 5; // Transfer error occurred + } + Status status = 2; + int32 next_chunk_expected = 3; // Next chunk number expected (for recovery) + string error_message = 4; // Error details if status is ERROR } // LockRequest is sent by the client to acquire or extend a lock on a device. diff --git a/protobuf/gen/dutctl/v1/dutctl.pb.go b/protobuf/gen/dutctl/v1/dutctl.pb.go index 9a54f6d9..57d094d1 100644 --- a/protobuf/gen/dutctl/v1/dutctl.pb.go +++ b/protobuf/gen/dutctl/v1/dutctl.pb.go @@ -21,6 +21,113 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type FileTransferRequest_Direction int32 + +const ( + FileTransferRequest_DIRECTION_UNSPECIFIED FileTransferRequest_Direction = 0 + FileTransferRequest_DIRECTION_UPLOAD FileTransferRequest_Direction = 1 // Client sending file to agent (agent requesting from client) + FileTransferRequest_DIRECTION_DOWNLOAD FileTransferRequest_Direction = 2 // Agent sending file to client +) + +// Enum value maps for FileTransferRequest_Direction. +var ( + FileTransferRequest_Direction_name = map[int32]string{ + 0: "DIRECTION_UNSPECIFIED", + 1: "DIRECTION_UPLOAD", + 2: "DIRECTION_DOWNLOAD", + } + FileTransferRequest_Direction_value = map[string]int32{ + "DIRECTION_UNSPECIFIED": 0, + "DIRECTION_UPLOAD": 1, + "DIRECTION_DOWNLOAD": 2, + } +) + +func (x FileTransferRequest_Direction) Enum() *FileTransferRequest_Direction { + p := new(FileTransferRequest_Direction) + *p = x + return p +} + +func (x FileTransferRequest_Direction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileTransferRequest_Direction) Descriptor() protoreflect.EnumDescriptor { + return file_dutctl_v1_dutctl_proto_enumTypes[0].Descriptor() +} + +func (FileTransferRequest_Direction) Type() protoreflect.EnumType { + return &file_dutctl_v1_dutctl_proto_enumTypes[0] +} + +func (x FileTransferRequest_Direction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileTransferRequest_Direction.Descriptor instead. +func (FileTransferRequest_Direction) EnumDescriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{15, 0} +} + +type FileTransferResponse_Status int32 + +const ( + FileTransferResponse_STATUS_UNSPECIFIED FileTransferResponse_Status = 0 + FileTransferResponse_STATUS_ACCEPTED FileTransferResponse_Status = 1 // Ready to receive chunks + FileTransferResponse_STATUS_CHUNK_RECEIVED FileTransferResponse_Status = 2 // Chunk received successfully + FileTransferResponse_STATUS_TRANSFER_COMPLETE FileTransferResponse_Status = 3 // All chunks received + FileTransferResponse_STATUS_TRANSFER_REJECTED FileTransferResponse_Status = 4 // Transfer cannot proceed + FileTransferResponse_STATUS_ERROR FileTransferResponse_Status = 5 // Transfer error occurred +) + +// Enum value maps for FileTransferResponse_Status. +var ( + FileTransferResponse_Status_name = map[int32]string{ + 0: "STATUS_UNSPECIFIED", + 1: "STATUS_ACCEPTED", + 2: "STATUS_CHUNK_RECEIVED", + 3: "STATUS_TRANSFER_COMPLETE", + 4: "STATUS_TRANSFER_REJECTED", + 5: "STATUS_ERROR", + } + FileTransferResponse_Status_value = map[string]int32{ + "STATUS_UNSPECIFIED": 0, + "STATUS_ACCEPTED": 1, + "STATUS_CHUNK_RECEIVED": 2, + "STATUS_TRANSFER_COMPLETE": 3, + "STATUS_TRANSFER_REJECTED": 4, + "STATUS_ERROR": 5, + } +) + +func (x FileTransferResponse_Status) Enum() *FileTransferResponse_Status { + p := new(FileTransferResponse_Status) + *p = x + return p +} + +func (x FileTransferResponse_Status) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FileTransferResponse_Status) Descriptor() protoreflect.EnumDescriptor { + return file_dutctl_v1_dutctl_proto_enumTypes[1].Descriptor() +} + +func (FileTransferResponse_Status) Type() protoreflect.EnumType { + return &file_dutctl_v1_dutctl_proto_enumTypes[1] +} + +func (x FileTransferResponse_Status) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FileTransferResponse_Status.Descriptor instead. +func (FileTransferResponse_Status) EnumDescriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{16, 0} +} + // ListRequest is sent by the client to request a list of devices connected to the agent. type ListRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -426,7 +533,9 @@ type RunRequest struct { // // *RunRequest_Command // *RunRequest_Console - // *RunRequest_File + // *RunRequest_FileTransferRequest + // *RunRequest_FileChunk + // *RunRequest_FileTransferResponse Msg isRunRequest_Msg `protobuf_oneof:"msg"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -487,10 +596,28 @@ func (x *RunRequest) GetConsole() *Console { return nil } -func (x *RunRequest) GetFile() *File { +func (x *RunRequest) GetFileTransferRequest() *FileTransferRequest { if x != nil { - if x, ok := x.Msg.(*RunRequest_File); ok { - return x.File + if x, ok := x.Msg.(*RunRequest_FileTransferRequest); ok { + return x.FileTransferRequest + } + } + return nil +} + +func (x *RunRequest) GetFileChunk() *FileChunk { + if x != nil { + if x, ok := x.Msg.(*RunRequest_FileChunk); ok { + return x.FileChunk + } + } + return nil +} + +func (x *RunRequest) GetFileTransferResponse() *FileTransferResponse { + if x != nil { + if x, ok := x.Msg.(*RunRequest_FileTransferResponse); ok { + return x.FileTransferResponse } } return nil @@ -508,15 +635,27 @@ type RunRequest_Console struct { Console *Console `protobuf:"bytes,2,opt,name=console,proto3,oneof"` } -type RunRequest_File struct { - File *File `protobuf:"bytes,3,opt,name=file,proto3,oneof"` +type RunRequest_FileTransferRequest struct { + FileTransferRequest *FileTransferRequest `protobuf:"bytes,5,opt,name=file_transfer_request,json=fileTransferRequest,proto3,oneof"` +} + +type RunRequest_FileChunk struct { + FileChunk *FileChunk `protobuf:"bytes,6,opt,name=file_chunk,json=fileChunk,proto3,oneof"` +} + +type RunRequest_FileTransferResponse struct { + FileTransferResponse *FileTransferResponse `protobuf:"bytes,7,opt,name=file_transfer_response,json=fileTransferResponse,proto3,oneof"` } func (*RunRequest_Command) isRunRequest_Msg() {} func (*RunRequest_Console) isRunRequest_Msg() {} -func (*RunRequest_File) isRunRequest_Msg() {} +func (*RunRequest_FileTransferRequest) isRunRequest_Msg() {} + +func (*RunRequest_FileChunk) isRunRequest_Msg() {} + +func (*RunRequest_FileTransferResponse) isRunRequest_Msg() {} // RunResponse is sent by the agent in response to a RunRequest and can either contain // just the output of the command (Print), or trigger further interaction with the client. @@ -526,8 +665,9 @@ type RunResponse struct { // // *RunResponse_Print // *RunResponse_Console - // *RunResponse_FileRequest - // *RunResponse_File + // *RunResponse_FileTransferRequest + // *RunResponse_FileChunk + // *RunResponse_FileTransferResponse Msg isRunResponse_Msg `protobuf_oneof:"msg"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -588,19 +728,28 @@ func (x *RunResponse) GetConsole() *Console { return nil } -func (x *RunResponse) GetFileRequest() *FileRequest { +func (x *RunResponse) GetFileTransferRequest() *FileTransferRequest { + if x != nil { + if x, ok := x.Msg.(*RunResponse_FileTransferRequest); ok { + return x.FileTransferRequest + } + } + return nil +} + +func (x *RunResponse) GetFileChunk() *FileChunk { if x != nil { - if x, ok := x.Msg.(*RunResponse_FileRequest); ok { - return x.FileRequest + if x, ok := x.Msg.(*RunResponse_FileChunk); ok { + return x.FileChunk } } return nil } -func (x *RunResponse) GetFile() *File { +func (x *RunResponse) GetFileTransferResponse() *FileTransferResponse { if x != nil { - if x, ok := x.Msg.(*RunResponse_File); ok { - return x.File + if x, ok := x.Msg.(*RunResponse_FileTransferResponse); ok { + return x.FileTransferResponse } } return nil @@ -618,21 +767,27 @@ type RunResponse_Console struct { Console *Console `protobuf:"bytes,2,opt,name=console,proto3,oneof"` } -type RunResponse_FileRequest struct { - FileRequest *FileRequest `protobuf:"bytes,3,opt,name=file_request,json=fileRequest,proto3,oneof"` +type RunResponse_FileTransferRequest struct { + FileTransferRequest *FileTransferRequest `protobuf:"bytes,5,opt,name=file_transfer_request,json=fileTransferRequest,proto3,oneof"` } -type RunResponse_File struct { - File *File `protobuf:"bytes,4,opt,name=file,proto3,oneof"` +type RunResponse_FileChunk struct { + FileChunk *FileChunk `protobuf:"bytes,6,opt,name=file_chunk,json=fileChunk,proto3,oneof"` +} + +type RunResponse_FileTransferResponse struct { + FileTransferResponse *FileTransferResponse `protobuf:"bytes,7,opt,name=file_transfer_response,json=fileTransferResponse,proto3,oneof"` } func (*RunResponse_Print) isRunResponse_Msg() {} func (*RunResponse_Console) isRunResponse_Msg() {} -func (*RunResponse_FileRequest) isRunResponse_Msg() {} +func (*RunResponse_FileTransferRequest) isRunResponse_Msg() {} + +func (*RunResponse_FileChunk) isRunResponse_Msg() {} -func (*RunResponse_File) isRunResponse_Msg() {} +func (*RunResponse_FileTransferResponse) isRunResponse_Msg() {} // Command is used by the client to start a command execution on a device. type Command struct { @@ -840,28 +995,30 @@ func (*Console_Stdout) isConsole_Data() {} func (*Console_Stderr) isConsole_Data() {} -// FileRequest is used by the agent to request a file from the client. -type FileRequest struct { +// FileMetadata describes the properties of a file being transferred. +type FileMetadata struct { state protoimpl.MessageState `protogen:"open.v1"` - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` // Path of the file + Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // Total file size in bytes + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` // Filename for reference unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *FileRequest) Reset() { - *x = FileRequest{} +func (x *FileMetadata) Reset() { + *x = FileMetadata{} mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *FileRequest) String() string { +func (x *FileMetadata) String() string { return protoimpl.X.MessageStringOf(x) } -func (*FileRequest) ProtoMessage() {} +func (*FileMetadata) ProtoMessage() {} -func (x *FileRequest) ProtoReflect() protoreflect.Message { +func (x *FileMetadata) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -873,41 +1030,59 @@ func (x *FileRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use FileRequest.ProtoReflect.Descriptor instead. -func (*FileRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use FileMetadata.ProtoReflect.Descriptor instead. +func (*FileMetadata) Descriptor() ([]byte, []int) { return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{13} } -func (x *FileRequest) GetPath() string { +func (x *FileMetadata) GetPath() string { if x != nil { return x.Path } return "" } -// File is used by the client and the agent to transfer a file. -type File struct { +func (x *FileMetadata) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +func (x *FileMetadata) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// FileChunk represents a single chunk of a file being transferred. +// Maximum chunk size is 1MB. +type FileChunk struct { state protoimpl.MessageState `protogen:"open.v1"` - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Content []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + TransferId string `protobuf:"bytes,1,opt,name=transfer_id,json=transferId,proto3" json:"transfer_id,omitempty"` // Unique ID for this transfer session + ChunkNumber int32 `protobuf:"varint,2,opt,name=chunk_number,json=chunkNumber,proto3" json:"chunk_number,omitempty"` // Sequential chunk number (0-indexed) + ChunkData []byte `protobuf:"bytes,3,opt,name=chunk_data,json=chunkData,proto3" json:"chunk_data,omitempty"` // File data chunk (max 1MB) + ChunkOffset int64 `protobuf:"varint,4,opt,name=chunk_offset,json=chunkOffset,proto3" json:"chunk_offset,omitempty"` // Byte offset in the file + IsFinal bool `protobuf:"varint,5,opt,name=is_final,json=isFinal,proto3" json:"is_final,omitempty"` // Whether this is the last chunk unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *File) Reset() { - *x = File{} +func (x *FileChunk) Reset() { + *x = FileChunk{} mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *File) String() string { +func (x *FileChunk) String() string { return protoimpl.X.MessageStringOf(x) } -func (*File) ProtoMessage() {} +func (*FileChunk) ProtoMessage() {} -func (x *File) ProtoReflect() protoreflect.Message { +func (x *FileChunk) ProtoReflect() protoreflect.Message { mi := &file_dutctl_v1_dutctl_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -919,25 +1094,177 @@ func (x *File) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use File.ProtoReflect.Descriptor instead. -func (*File) Descriptor() ([]byte, []int) { +// Deprecated: Use FileChunk.ProtoReflect.Descriptor instead. +func (*FileChunk) Descriptor() ([]byte, []int) { return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{14} } -func (x *File) GetPath() string { +func (x *FileChunk) GetTransferId() string { if x != nil { - return x.Path + return x.TransferId } return "" } -func (x *File) GetContent() []byte { +func (x *FileChunk) GetChunkNumber() int32 { if x != nil { - return x.Content + return x.ChunkNumber + } + return 0 +} + +func (x *FileChunk) GetChunkData() []byte { + if x != nil { + return x.ChunkData } return nil } +func (x *FileChunk) GetChunkOffset() int64 { + if x != nil { + return x.ChunkOffset + } + return 0 +} + +func (x *FileChunk) GetIsFinal() bool { + if x != nil { + return x.IsFinal + } + return false +} + +// FileTransferRequest initiates a file transfer. +// Sent by the side requesting to receive a file. +type FileTransferRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TransferId string `protobuf:"bytes,1,opt,name=transfer_id,json=transferId,proto3" json:"transfer_id,omitempty"` // Unique ID for this transfer + Metadata *FileMetadata `protobuf:"bytes,2,opt,name=metadata,proto3" json:"metadata,omitempty"` // File metadata + Direction FileTransferRequest_Direction `protobuf:"varint,3,opt,name=direction,proto3,enum=dutctl.v1.FileTransferRequest_Direction" json:"direction,omitempty"` // Direction of file transfer + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileTransferRequest) Reset() { + *x = FileTransferRequest{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileTransferRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileTransferRequest) ProtoMessage() {} + +func (x *FileTransferRequest) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileTransferRequest.ProtoReflect.Descriptor instead. +func (*FileTransferRequest) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{15} +} + +func (x *FileTransferRequest) GetTransferId() string { + if x != nil { + return x.TransferId + } + return "" +} + +func (x *FileTransferRequest) GetMetadata() *FileMetadata { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *FileTransferRequest) GetDirection() FileTransferRequest_Direction { + if x != nil { + return x.Direction + } + return FileTransferRequest_DIRECTION_UNSPECIFIED +} + +// FileTransferResponse acknowledges file transfer requests and chunk receipts. +type FileTransferResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + TransferId string `protobuf:"bytes,1,opt,name=transfer_id,json=transferId,proto3" json:"transfer_id,omitempty"` // Matches request ID + Status FileTransferResponse_Status `protobuf:"varint,2,opt,name=status,proto3,enum=dutctl.v1.FileTransferResponse_Status" json:"status,omitempty"` + NextChunkExpected int32 `protobuf:"varint,3,opt,name=next_chunk_expected,json=nextChunkExpected,proto3" json:"next_chunk_expected,omitempty"` // Next chunk number expected (for recovery) + ErrorMessage string `protobuf:"bytes,4,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` // Error details if status is ERROR + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FileTransferResponse) Reset() { + *x = FileTransferResponse{} + mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FileTransferResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FileTransferResponse) ProtoMessage() {} + +func (x *FileTransferResponse) ProtoReflect() protoreflect.Message { + mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FileTransferResponse.ProtoReflect.Descriptor instead. +func (*FileTransferResponse) Descriptor() ([]byte, []int) { + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{16} +} + +func (x *FileTransferResponse) GetTransferId() string { + if x != nil { + return x.TransferId + } + return "" +} + +func (x *FileTransferResponse) GetStatus() FileTransferResponse_Status { + if x != nil { + return x.Status + } + return FileTransferResponse_STATUS_UNSPECIFIED +} + +func (x *FileTransferResponse) GetNextChunkExpected() int32 { + if x != nil { + return x.NextChunkExpected + } + return 0 +} + +func (x *FileTransferResponse) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + // LockRequest is sent by the client to acquire or extend a lock on a device. // The lock owner identity is carried in an HTTP header, not in this message. // @@ -954,7 +1281,7 @@ type LockRequest struct { func (x *LockRequest) Reset() { *x = LockRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -966,7 +1293,7 @@ func (x *LockRequest) String() string { func (*LockRequest) ProtoMessage() {} func (x *LockRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[15] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -979,7 +1306,7 @@ func (x *LockRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LockRequest.ProtoReflect.Descriptor instead. func (*LockRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{15} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{17} } func (x *LockRequest) GetDevice() string { @@ -1007,7 +1334,7 @@ type LockResponse struct { func (x *LockResponse) Reset() { *x = LockResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1019,7 +1346,7 @@ func (x *LockResponse) String() string { func (*LockResponse) ProtoMessage() {} func (x *LockResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[16] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1032,7 +1359,7 @@ func (x *LockResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LockResponse.ProtoReflect.Descriptor instead. func (*LockResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{16} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{18} } func (x *LockResponse) GetDevice() string { @@ -1061,7 +1388,7 @@ type UnlockRequest struct { func (x *UnlockRequest) Reset() { *x = UnlockRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1073,7 +1400,7 @@ func (x *UnlockRequest) String() string { func (*UnlockRequest) ProtoMessage() {} func (x *UnlockRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[17] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1086,7 +1413,7 @@ func (x *UnlockRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockRequest.ProtoReflect.Descriptor instead. func (*UnlockRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{17} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{19} } func (x *UnlockRequest) GetDevice() string { @@ -1112,7 +1439,7 @@ type UnlockResponse struct { func (x *UnlockResponse) Reset() { *x = UnlockResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1124,7 +1451,7 @@ func (x *UnlockResponse) String() string { func (*UnlockResponse) ProtoMessage() {} func (x *UnlockResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[18] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1137,7 +1464,7 @@ func (x *UnlockResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnlockResponse.ProtoReflect.Descriptor instead. func (*UnlockResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{18} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{20} } // RegisterRequest is sent by a device agent to register with the relay server. @@ -1152,7 +1479,7 @@ type RegisterRequest struct { func (x *RegisterRequest) Reset() { *x = RegisterRequest{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1164,7 +1491,7 @@ func (x *RegisterRequest) String() string { func (*RegisterRequest) ProtoMessage() {} func (x *RegisterRequest) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[19] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1177,7 +1504,7 @@ func (x *RegisterRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterRequest.ProtoReflect.Descriptor instead. func (*RegisterRequest) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{19} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{21} } func (x *RegisterRequest) GetDevices() []string { @@ -1204,7 +1531,7 @@ type RegisterResponse struct { func (x *RegisterResponse) Reset() { *x = RegisterResponse{} - mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1216,7 +1543,7 @@ func (x *RegisterResponse) String() string { func (*RegisterResponse) ProtoMessage() {} func (x *RegisterResponse) ProtoReflect() protoreflect.Message { - mi := &file_dutctl_v1_dutctl_proto_msgTypes[20] + mi := &file_dutctl_v1_dutctl_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1229,7 +1556,7 @@ func (x *RegisterResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RegisterResponse.ProtoReflect.Descriptor instead. func (*RegisterResponse) Descriptor() ([]byte, []int) { - return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{20} + return file_dutctl_v1_dutctl_proto_rawDescGZIP(), []int{22} } var File_dutctl_v1_dutctl_proto protoreflect.FileDescriptor @@ -1258,19 +1585,24 @@ const file_dutctl_v1_dutctl_proto_rawDesc = "" + "\acommand\x18\x02 \x01(\tR\acommand\x12\x18\n" + "\akeyword\x18\x03 \x01(\tR\akeyword\"+\n" + "\x0fDetailsResponse\x12\x18\n" + - "\adetails\x18\x01 \x01(\tR\adetails\"\x9a\x01\n" + + "\adetails\x18\x01 \x01(\tR\adetails\"\xeb\x02\n" + "\n" + "RunRequest\x12.\n" + "\acommand\x18\x01 \x01(\v2\x12.dutctl.v1.CommandH\x00R\acommand\x12.\n" + - "\aconsole\x18\x02 \x01(\v2\x12.dutctl.v1.ConsoleH\x00R\aconsole\x12%\n" + - "\x04file\x18\x03 \x01(\v2\x0f.dutctl.v1.FileH\x00R\x04fileB\x05\n" + - "\x03msg\"\xd2\x01\n" + + "\aconsole\x18\x02 \x01(\v2\x12.dutctl.v1.ConsoleH\x00R\aconsole\x12T\n" + + "\x15file_transfer_request\x18\x05 \x01(\v2\x1e.dutctl.v1.FileTransferRequestH\x00R\x13fileTransferRequest\x125\n" + + "\n" + + "file_chunk\x18\x06 \x01(\v2\x14.dutctl.v1.FileChunkH\x00R\tfileChunk\x12W\n" + + "\x16file_transfer_response\x18\a \x01(\v2\x1f.dutctl.v1.FileTransferResponseH\x00R\x14fileTransferResponseB\x05\n" + + "\x03msgJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\x04file\"\xf4\x02\n" + "\vRunResponse\x12(\n" + "\x05print\x18\x01 \x01(\v2\x10.dutctl.v1.PrintH\x00R\x05print\x12.\n" + - "\aconsole\x18\x02 \x01(\v2\x12.dutctl.v1.ConsoleH\x00R\aconsole\x12;\n" + - "\ffile_request\x18\x03 \x01(\v2\x16.dutctl.v1.FileRequestH\x00R\vfileRequest\x12%\n" + - "\x04file\x18\x04 \x01(\v2\x0f.dutctl.v1.FileH\x00R\x04fileB\x05\n" + - "\x03msg\"O\n" + + "\aconsole\x18\x02 \x01(\v2\x12.dutctl.v1.ConsoleH\x00R\aconsole\x12T\n" + + "\x15file_transfer_request\x18\x05 \x01(\v2\x1e.dutctl.v1.FileTransferRequestH\x00R\x13fileTransferRequest\x125\n" + + "\n" + + "file_chunk\x18\x06 \x01(\v2\x14.dutctl.v1.FileChunkH\x00R\tfileChunk\x12W\n" + + "\x16file_transfer_response\x18\a \x01(\v2\x1f.dutctl.v1.FileTransferResponseH\x00R\x14fileTransferResponseB\x05\n" + + "\x03msgJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\x04fileR\ffile_request\"O\n" + "\aCommand\x12\x16\n" + "\x06device\x18\x01 \x01(\tR\x06device\x12\x18\n" + "\acommand\x18\x02 \x01(\tR\acommand\x12\x12\n" + @@ -1281,12 +1613,41 @@ const file_dutctl_v1_dutctl_proto_rawDesc = "" + "\x05stdin\x18\x01 \x01(\fH\x00R\x05stdin\x12\x18\n" + "\x06stdout\x18\x02 \x01(\fH\x00R\x06stdout\x12\x18\n" + "\x06stderr\x18\x03 \x01(\fH\x00R\x06stderrB\x06\n" + - "\x04data\"!\n" + - "\vFileRequest\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\"4\n" + - "\x04File\x12\x12\n" + - "\x04path\x18\x01 \x01(\tR\x04path\x12\x18\n" + - "\acontent\x18\x02 \x01(\fR\acontent\"P\n" + + "\x04data\"J\n" + + "\fFileMetadata\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" + + "\x04size\x18\x02 \x01(\x03R\x04size\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\"\xac\x01\n" + + "\tFileChunk\x12\x1f\n" + + "\vtransfer_id\x18\x01 \x01(\tR\n" + + "transferId\x12!\n" + + "\fchunk_number\x18\x02 \x01(\x05R\vchunkNumber\x12\x1d\n" + + "\n" + + "chunk_data\x18\x03 \x01(\fR\tchunkData\x12!\n" + + "\fchunk_offset\x18\x04 \x01(\x03R\vchunkOffset\x12\x19\n" + + "\bis_final\x18\x05 \x01(\bR\aisFinal\"\x89\x02\n" + + "\x13FileTransferRequest\x12\x1f\n" + + "\vtransfer_id\x18\x01 \x01(\tR\n" + + "transferId\x123\n" + + "\bmetadata\x18\x02 \x01(\v2\x17.dutctl.v1.FileMetadataR\bmetadata\x12F\n" + + "\tdirection\x18\x03 \x01(\x0e2(.dutctl.v1.FileTransferRequest.DirectionR\tdirection\"T\n" + + "\tDirection\x12\x19\n" + + "\x15DIRECTION_UNSPECIFIED\x10\x00\x12\x14\n" + + "\x10DIRECTION_UPLOAD\x10\x01\x12\x16\n" + + "\x12DIRECTION_DOWNLOAD\x10\x02\"\xed\x02\n" + + "\x14FileTransferResponse\x12\x1f\n" + + "\vtransfer_id\x18\x01 \x01(\tR\n" + + "transferId\x12>\n" + + "\x06status\x18\x02 \x01(\x0e2&.dutctl.v1.FileTransferResponse.StatusR\x06status\x12.\n" + + "\x13next_chunk_expected\x18\x03 \x01(\x05R\x11nextChunkExpected\x12#\n" + + "\rerror_message\x18\x04 \x01(\tR\ferrorMessage\"\x9e\x01\n" + + "\x06Status\x12\x16\n" + + "\x12STATUS_UNSPECIFIED\x10\x00\x12\x13\n" + + "\x0fSTATUS_ACCEPTED\x10\x01\x12\x19\n" + + "\x15STATUS_CHUNK_RECEIVED\x10\x02\x12\x1c\n" + + "\x18STATUS_TRANSFER_COMPLETE\x10\x03\x12\x1c\n" + + "\x18STATUS_TRANSFER_REJECTED\x10\x04\x12\x10\n" + + "\fSTATUS_ERROR\x10\x05\"P\n" + "\vLockRequest\x12\x16\n" + "\x06device\x18\x01 \x01(\tR\x06device\x12)\n" + "\x10duration_seconds\x18\x02 \x01(\x03R\x0fdurationSeconds\"P\n" + @@ -1323,60 +1684,71 @@ func file_dutctl_v1_dutctl_proto_rawDescGZIP() []byte { return file_dutctl_v1_dutctl_proto_rawDescData } -var file_dutctl_v1_dutctl_proto_msgTypes = make([]protoimpl.MessageInfo, 21) +var file_dutctl_v1_dutctl_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_dutctl_v1_dutctl_proto_msgTypes = make([]protoimpl.MessageInfo, 23) var file_dutctl_v1_dutctl_proto_goTypes = []any{ - (*ListRequest)(nil), // 0: dutctl.v1.ListRequest - (*ListResponse)(nil), // 1: dutctl.v1.ListResponse - (*DeviceInfo)(nil), // 2: dutctl.v1.DeviceInfo - (*LockState)(nil), // 3: dutctl.v1.LockState - (*CommandsRequest)(nil), // 4: dutctl.v1.CommandsRequest - (*CommandsResponse)(nil), // 5: dutctl.v1.CommandsResponse - (*DetailsRequest)(nil), // 6: dutctl.v1.DetailsRequest - (*DetailsResponse)(nil), // 7: dutctl.v1.DetailsResponse - (*RunRequest)(nil), // 8: dutctl.v1.RunRequest - (*RunResponse)(nil), // 9: dutctl.v1.RunResponse - (*Command)(nil), // 10: dutctl.v1.Command - (*Print)(nil), // 11: dutctl.v1.Print - (*Console)(nil), // 12: dutctl.v1.Console - (*FileRequest)(nil), // 13: dutctl.v1.FileRequest - (*File)(nil), // 14: dutctl.v1.File - (*LockRequest)(nil), // 15: dutctl.v1.LockRequest - (*LockResponse)(nil), // 16: dutctl.v1.LockResponse - (*UnlockRequest)(nil), // 17: dutctl.v1.UnlockRequest - (*UnlockResponse)(nil), // 18: dutctl.v1.UnlockResponse - (*RegisterRequest)(nil), // 19: dutctl.v1.RegisterRequest - (*RegisterResponse)(nil), // 20: dutctl.v1.RegisterResponse + (FileTransferRequest_Direction)(0), // 0: dutctl.v1.FileTransferRequest.Direction + (FileTransferResponse_Status)(0), // 1: dutctl.v1.FileTransferResponse.Status + (*ListRequest)(nil), // 2: dutctl.v1.ListRequest + (*ListResponse)(nil), // 3: dutctl.v1.ListResponse + (*DeviceInfo)(nil), // 4: dutctl.v1.DeviceInfo + (*LockState)(nil), // 5: dutctl.v1.LockState + (*CommandsRequest)(nil), // 6: dutctl.v1.CommandsRequest + (*CommandsResponse)(nil), // 7: dutctl.v1.CommandsResponse + (*DetailsRequest)(nil), // 8: dutctl.v1.DetailsRequest + (*DetailsResponse)(nil), // 9: dutctl.v1.DetailsResponse + (*RunRequest)(nil), // 10: dutctl.v1.RunRequest + (*RunResponse)(nil), // 11: dutctl.v1.RunResponse + (*Command)(nil), // 12: dutctl.v1.Command + (*Print)(nil), // 13: dutctl.v1.Print + (*Console)(nil), // 14: dutctl.v1.Console + (*FileMetadata)(nil), // 15: dutctl.v1.FileMetadata + (*FileChunk)(nil), // 16: dutctl.v1.FileChunk + (*FileTransferRequest)(nil), // 17: dutctl.v1.FileTransferRequest + (*FileTransferResponse)(nil), // 18: dutctl.v1.FileTransferResponse + (*LockRequest)(nil), // 19: dutctl.v1.LockRequest + (*LockResponse)(nil), // 20: dutctl.v1.LockResponse + (*UnlockRequest)(nil), // 21: dutctl.v1.UnlockRequest + (*UnlockResponse)(nil), // 22: dutctl.v1.UnlockResponse + (*RegisterRequest)(nil), // 23: dutctl.v1.RegisterRequest + (*RegisterResponse)(nil), // 24: dutctl.v1.RegisterResponse } var file_dutctl_v1_dutctl_proto_depIdxs = []int32{ - 2, // 0: dutctl.v1.ListResponse.devices:type_name -> dutctl.v1.DeviceInfo - 3, // 1: dutctl.v1.DeviceInfo.lock:type_name -> dutctl.v1.LockState - 10, // 2: dutctl.v1.RunRequest.command:type_name -> dutctl.v1.Command - 12, // 3: dutctl.v1.RunRequest.console:type_name -> dutctl.v1.Console - 14, // 4: dutctl.v1.RunRequest.file:type_name -> dutctl.v1.File - 11, // 5: dutctl.v1.RunResponse.print:type_name -> dutctl.v1.Print - 12, // 6: dutctl.v1.RunResponse.console:type_name -> dutctl.v1.Console - 13, // 7: dutctl.v1.RunResponse.file_request:type_name -> dutctl.v1.FileRequest - 14, // 8: dutctl.v1.RunResponse.file:type_name -> dutctl.v1.File - 3, // 9: dutctl.v1.LockResponse.lock:type_name -> dutctl.v1.LockState - 0, // 10: dutctl.v1.DeviceService.List:input_type -> dutctl.v1.ListRequest - 4, // 11: dutctl.v1.DeviceService.Commands:input_type -> dutctl.v1.CommandsRequest - 6, // 12: dutctl.v1.DeviceService.Details:input_type -> dutctl.v1.DetailsRequest - 8, // 13: dutctl.v1.DeviceService.Run:input_type -> dutctl.v1.RunRequest - 15, // 14: dutctl.v1.DeviceService.Lock:input_type -> dutctl.v1.LockRequest - 17, // 15: dutctl.v1.DeviceService.Unlock:input_type -> dutctl.v1.UnlockRequest - 19, // 16: dutctl.v1.RelayService.Register:input_type -> dutctl.v1.RegisterRequest - 1, // 17: dutctl.v1.DeviceService.List:output_type -> dutctl.v1.ListResponse - 5, // 18: dutctl.v1.DeviceService.Commands:output_type -> dutctl.v1.CommandsResponse - 7, // 19: dutctl.v1.DeviceService.Details:output_type -> dutctl.v1.DetailsResponse - 9, // 20: dutctl.v1.DeviceService.Run:output_type -> dutctl.v1.RunResponse - 16, // 21: dutctl.v1.DeviceService.Lock:output_type -> dutctl.v1.LockResponse - 18, // 22: dutctl.v1.DeviceService.Unlock:output_type -> dutctl.v1.UnlockResponse - 20, // 23: dutctl.v1.RelayService.Register:output_type -> dutctl.v1.RegisterResponse - 17, // [17:24] is the sub-list for method output_type - 10, // [10:17] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 4, // 0: dutctl.v1.ListResponse.devices:type_name -> dutctl.v1.DeviceInfo + 5, // 1: dutctl.v1.DeviceInfo.lock:type_name -> dutctl.v1.LockState + 12, // 2: dutctl.v1.RunRequest.command:type_name -> dutctl.v1.Command + 14, // 3: dutctl.v1.RunRequest.console:type_name -> dutctl.v1.Console + 17, // 4: dutctl.v1.RunRequest.file_transfer_request:type_name -> dutctl.v1.FileTransferRequest + 16, // 5: dutctl.v1.RunRequest.file_chunk:type_name -> dutctl.v1.FileChunk + 18, // 6: dutctl.v1.RunRequest.file_transfer_response:type_name -> dutctl.v1.FileTransferResponse + 13, // 7: dutctl.v1.RunResponse.print:type_name -> dutctl.v1.Print + 14, // 8: dutctl.v1.RunResponse.console:type_name -> dutctl.v1.Console + 17, // 9: dutctl.v1.RunResponse.file_transfer_request:type_name -> dutctl.v1.FileTransferRequest + 16, // 10: dutctl.v1.RunResponse.file_chunk:type_name -> dutctl.v1.FileChunk + 18, // 11: dutctl.v1.RunResponse.file_transfer_response:type_name -> dutctl.v1.FileTransferResponse + 15, // 12: dutctl.v1.FileTransferRequest.metadata:type_name -> dutctl.v1.FileMetadata + 0, // 13: dutctl.v1.FileTransferRequest.direction:type_name -> dutctl.v1.FileTransferRequest.Direction + 1, // 14: dutctl.v1.FileTransferResponse.status:type_name -> dutctl.v1.FileTransferResponse.Status + 5, // 15: dutctl.v1.LockResponse.lock:type_name -> dutctl.v1.LockState + 2, // 16: dutctl.v1.DeviceService.List:input_type -> dutctl.v1.ListRequest + 6, // 17: dutctl.v1.DeviceService.Commands:input_type -> dutctl.v1.CommandsRequest + 8, // 18: dutctl.v1.DeviceService.Details:input_type -> dutctl.v1.DetailsRequest + 10, // 19: dutctl.v1.DeviceService.Run:input_type -> dutctl.v1.RunRequest + 19, // 20: dutctl.v1.DeviceService.Lock:input_type -> dutctl.v1.LockRequest + 21, // 21: dutctl.v1.DeviceService.Unlock:input_type -> dutctl.v1.UnlockRequest + 23, // 22: dutctl.v1.RelayService.Register:input_type -> dutctl.v1.RegisterRequest + 3, // 23: dutctl.v1.DeviceService.List:output_type -> dutctl.v1.ListResponse + 7, // 24: dutctl.v1.DeviceService.Commands:output_type -> dutctl.v1.CommandsResponse + 9, // 25: dutctl.v1.DeviceService.Details:output_type -> dutctl.v1.DetailsResponse + 11, // 26: dutctl.v1.DeviceService.Run:output_type -> dutctl.v1.RunResponse + 20, // 27: dutctl.v1.DeviceService.Lock:output_type -> dutctl.v1.LockResponse + 22, // 28: dutctl.v1.DeviceService.Unlock:output_type -> dutctl.v1.UnlockResponse + 24, // 29: dutctl.v1.RelayService.Register:output_type -> dutctl.v1.RegisterResponse + 23, // [23:30] is the sub-list for method output_type + 16, // [16:23] is the sub-list for method input_type + 16, // [16:16] is the sub-list for extension type_name + 16, // [16:16] is the sub-list for extension extendee + 0, // [0:16] is the sub-list for field type_name } func init() { file_dutctl_v1_dutctl_proto_init() } @@ -1387,13 +1759,16 @@ func file_dutctl_v1_dutctl_proto_init() { file_dutctl_v1_dutctl_proto_msgTypes[8].OneofWrappers = []any{ (*RunRequest_Command)(nil), (*RunRequest_Console)(nil), - (*RunRequest_File)(nil), + (*RunRequest_FileTransferRequest)(nil), + (*RunRequest_FileChunk)(nil), + (*RunRequest_FileTransferResponse)(nil), } file_dutctl_v1_dutctl_proto_msgTypes[9].OneofWrappers = []any{ (*RunResponse_Print)(nil), (*RunResponse_Console)(nil), - (*RunResponse_FileRequest)(nil), - (*RunResponse_File)(nil), + (*RunResponse_FileTransferRequest)(nil), + (*RunResponse_FileChunk)(nil), + (*RunResponse_FileTransferResponse)(nil), } file_dutctl_v1_dutctl_proto_msgTypes[12].OneofWrappers = []any{ (*Console_Stdin)(nil), @@ -1405,13 +1780,14 @@ func file_dutctl_v1_dutctl_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_dutctl_v1_dutctl_proto_rawDesc), len(file_dutctl_v1_dutctl_proto_rawDesc)), - NumEnums: 0, - NumMessages: 21, + NumEnums: 2, + NumMessages: 23, NumExtensions: 0, NumServices: 2, }, GoTypes: file_dutctl_v1_dutctl_proto_goTypes, DependencyIndexes: file_dutctl_v1_dutctl_proto_depIdxs, + EnumInfos: file_dutctl_v1_dutctl_proto_enumTypes, MessageInfos: file_dutctl_v1_dutctl_proto_msgTypes, }.Build() File_dutctl_v1_dutctl_proto = out.File