Internal change PiperOrigin-RevId: 530882356 Change-Id: I75666efa036e514cb18ec597a3c41bd27f738456
diff --git a/GOIMPORT/AUTOPATCHES/tests/basic_test.go.patch b/GOIMPORT/AUTOPATCHES/tests/basic_test.go.patch new file mode 100644 index 0000000..ee314f6 --- /dev/null +++ b/GOIMPORT/AUTOPATCHES/tests/basic_test.go.patch
@@ -0,0 +1,11 @@ +# This file reports differences detected by import.go since the last import. +# This is only for human review and not machine consumption. +# Please see go/thirdpartygo for more information. +# DO NOT EDIT. This must only be generated by //third_party/golang/import.go. + +@@ +-package tests ++package tests_test + + import ( + "context"
diff --git a/GOIMPORT/CONFIGURATION b/GOIMPORT/CONFIGURATION new file mode 100644 index 0000000..d7c8f0d --- /dev/null +++ b/GOIMPORT/CONFIGURATION
@@ -0,0 +1,43 @@ +# This file configures imports managed by //third_party/golang/import.go. +# See go/thirdpartygo for more information. + +# ImportFiles specifies which files to import from the upstream source. +[ImportFiles] + exclude: /[.] # exclude hidden files and folders (e.g., ".gitignore") + exclude: /METADATA$ # see go/metadata + exclude: /OWNERS$ # see go/owners + exclude: /BUILD$ # see go/build + include: . + +# ImportRenames specifies whether to rename any source files or directories. +[ImportRenames] + # Rename common names for the LICENSE file; adjust if necessary. + sed: s:^/LICEN[CS]E([.](gpl|md|txt))?$:/LICENSE:I # see go/thirdpartylicenses + + # Mangle special characters to comply with Piper and Blaze limitations. + sed: `:loop; s:/(.*)[.-](.*)/:/\1_\2/:; t loop;` # dots/dashes in directories to underscores + sed: "s:[ ]:_:g" # spaces to underscores + sed: "s:[()]::g" # remove parentheses + +# RewriteFiles specifies which Go source files to rewrite import paths for. +[RewriteFiles] + exclude: /testdata/ + exclude: /[._][^/]*$ + include: . + +# GoogleFiles specifies files added to the import to support use within google3. +[GoogleFiles] + include: ^/GOIMPORT/ # configuration and metadata for import.go tool + include: /METADATA # see go/metadata + include: /OWNERS$ # see go/owners + include: /BUILD$ # see go/build + include: /g3doc/ # see go/g3doc + include: /bluze\.textproto$ # see go/bluze + include: /[^/]+\.blueprint$ # see go/blueprint + include: /copy\.bara\.sky$ # see go/copybara-tp-golang + + # Treat files with google_ prefix as Google-internal. This may conflict with + # actual files upstream with a google_ prefix; delete if necessary. + include: /google_[^/]+$ + + exclude: .
diff --git a/GOIMPORT/MANIFEST b/GOIMPORT/MANIFEST new file mode 100644 index 0000000..7e1175a --- /dev/null +++ b/GOIMPORT/MANIFEST
@@ -0,0 +1,27 @@ +# This file contains a manifest of all files imported by import.go. +# This is for both human review and machine consumption. +# Please see go/thirdpartygo for more information. +# DO NOT EDIT. This must only be generated by //third_party/golang/import.go. + ++ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx BUILD ++ f8197fcdb088934eb7064177aa870e36 GOIMPORT/AUTOPATCHES/tests/basic_test.go.patch ++ c7b8b763665961ebdfb805595260035e GOIMPORT/CONFIGURATION ++ a6594993e651222a0776d69e53218d6a GOIMPORT/MANIFEST += f4f3830490c360b77a253a284506699c LICENSE ++ 409bb1af117f905fb6f00dcc4feeac45 METADATA += 8666fba44b51ae89b85bebf80e31363f README.md ++ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx auth/BUILD += 9ebd04522e3df08b3a8a1e62c8db7eae auth/key.go += 880a3308fa8fd7dd5cfe34aa35c7906c client.go += 7c85b7caff69baefbf4e7e16f0d8b27c configurer.go += dd7cc13fc35addb549c4100a622e901f go.mod += fae750ae9da460fea138f1ea3eb50ded go.sum += b0c6e10a5c9edf076f2416fbbe33f4c2 protocol.go += 41bd693574e6d41bbafd2f4b215da976 scp.go ++ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx tests/BUILD +~ 36aa47365f5c161f3f425c9b31cd599a tests/basic_test.go += 4f146b00cd6cd8d20678db1b685e47e4 tests/data/Exöt1ç_download_file.txt.txt += b3d0330c7ba306c72e1717ea6222a6a8 tests/data/upload_file.txt += 166b7213b0e6ec4dc6cdc6bc2c57fc42 tests/entrypoint_d/setpasswd.sh += b0e8784a99dacc01286aeec511fad91c tests/run_all.sh += b8cc39b0dfc9400416a80138e5c24674 utils.go
diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14e2f77 --- /dev/null +++ b/LICENSE
@@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0.
diff --git a/README.md b/README.md new file mode 100644 index 0000000..041988d --- /dev/null +++ b/README.md
@@ -0,0 +1,97 @@ +Copy files over SCP with Go +============================= +[](https://goreportcard.com/report/bramvdbogaerde/go-scp) [](https://godoc.org/github.com/bramvdbogaerde/go-scp) + +This package makes it very easy to copy files over scp in Go. +It uses the golang.org/x/crypto/ssh package to establish a secure connection to a remote server in order to copy the files via the SCP protocol. + +### Example usage + + +```go +package main + +import ( + "fmt" + scp "github.com/bramvdbogaerde/go-scp" + "github.com/bramvdbogaerde/go-scp/auth" + "golang.org/x/crypto/ssh" + "os" + "context" +) + +func main() { + // Use SSH key authentication from the auth package + // we ignore the host key in this example, please change this if you use this library + clientConfig, _ := auth.PrivateKey("username", "/path/to/rsa/key", ssh.InsecureIgnoreHostKey()) + + // For other authentication methods see ssh.ClientConfig and ssh.AuthMethod + + // Create a new SCP client + client := scp.NewClient("example.com:22", &clientConfig) + + // Connect to the remote server + err := client.Connect() + if err != nil { + fmt.Println("Couldn't establish a connection to the remote server ", err) + return + } + + // Open a file + f, _ := os.Open("/path/to/local/file") + + // Close client connection after the file has been copied + defer client.Close() + + // Close the file after it has been copied + defer f.Close() + + // Finaly, copy the file over + // Usage: CopyFromFile(context, file, remotePath, permission) + + // the context can be adjusted to provide time-outs or inherit from other contexts if this is embedded in a larger application. + err = client.CopyFromFile(context.Background(), *f, "/home/server/test.txt", "0655") + + if err != nil { + fmt.Println("Error while copying file ", err) + } +} +``` + +#### Using an existing SSH connection + +If you have an existing established SSH connection, you can use that instead. + +```go +func connectSSH() *ssh.Client { + // setup SSH connection +} + +func main() { + sshClient := connectSSH() + + // Create a new SCP client, note that this function might + // return an error, as a new SSH session is established using the existing connecton + + client, err := scp.NewClientBySSH(sshClient) + if err != nil { + fmt.Println("Error creating new SSH session from existing connection", err) + } + + /* .. same as above .. */ +} +``` + +#### Copying Files from Remote Server + +It is also possible to copy remote files using this library. +The usage is similar to the example at the top of this section, except that `CopyFromRemote` needsto be used instead. + +For a more comprehensive example, please consult the `TestDownloadFile` function in t he `tests/basic_test.go` file. + +### License + +This library is licensed under the Mozilla Public License 2.0. +A copy of the license is provided in the `LICENSE.txt` file. + +Copyright (c) 2020 Bram Vandenbogaerde
diff --git a/auth/key.go b/auth/key.go new file mode 100644 index 0000000..6d3cb9b --- /dev/null +++ b/auth/key.go
@@ -0,0 +1,90 @@ +/* Copyright (c) 2020 Bram Vandenbogaerde + * You may use, distribute or modify this code under the + * terms of the Mozilla Public License 2.0, which is distributed + * along with the source code. + */ +package auth + +import ( + "io/ioutil" + "net" + "os" + + "google3/third_party/golang/go_crypto/ssh/agent/agent" + "google3/third_party/golang/go_crypto/ssh/ssh" +) + +// PrivateKey Loads a private and public key from "path" and returns a SSH ClientConfig to authenticate with the server +func PrivateKey(username string, path string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { + privateKey, err := ioutil.ReadFile(path) + + if err != nil { + return ssh.ClientConfig{}, err + } + + signer, err := ssh.ParsePrivateKey(privateKey) + + if err != nil { + return ssh.ClientConfig{}, err + } + + return ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: keyCallBack, + }, nil +} + +// Creates the configuration for a client that authenticates with a password protected private key +func PrivateKeyWithPassphrase(username string, passpharase []byte, path string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { + privateKey, err := ioutil.ReadFile(path) + + if err != nil { + return ssh.ClientConfig{}, err + } + signer, err := ssh.ParsePrivateKeyWithPassphrase(privateKey, passpharase) + + if err != nil { + return ssh.ClientConfig{}, err + } + + return ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: keyCallBack, + }, nil +} + +// Creates a configuration for a client that fetches public-private key from the SSH agent for authentication +func SshAgent(username string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { + socket := os.Getenv("SSH_AUTH_SOCK") + conn, err := net.Dial("unix", socket) + if err != nil { + return ssh.ClientConfig{}, err + } + + agentClient := agent.NewClient(conn) + return ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.PublicKeysCallback(agentClient.Signers), + }, + HostKeyCallback: keyCallBack, + }, nil +} + +// Creates a configuration for a client that authenticates using username and password +func PasswordKey(username string, password string, keyCallBack ssh.HostKeyCallback) (ssh.ClientConfig, error) { + + return ssh.ClientConfig{ + User: username, + Auth: []ssh.AuthMethod{ + ssh.Password(password), + }, + HostKeyCallback: keyCallBack, + }, nil +}
diff --git a/client.go b/client.go new file mode 100644 index 0000000..00f1729 --- /dev/null +++ b/client.go
@@ -0,0 +1,341 @@ +/* Copyright (c) 2021 Bram Vandenbogaerde And Contributors + * You may use, distribute or modify this code under the + * terms of the Mozilla Public License 2.0, which is distributed + * along with the source code. + */ + +package scp + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "path" + "sync" + "time" + + "google3/third_party/golang/go_crypto/ssh/ssh" +) + +type PassThru func(r io.Reader, total int64) io.Reader + +type Client struct { + // Host the host to connect to. + Host string + + // ClientConfig the client config to use. + ClientConfig *ssh.ClientConfig + + // Session stores the SSH session while the connection is running. + Session *ssh.Session + + // Conn stores the SSH connection itself in order to close it after transfer. + Conn ssh.Conn + + // Timeout the maximal amount of time to wait for a file transfer to complete. + // Deprecated: use context.Context for each function instead. + Timeout time.Duration + + // RemoteBinary the absolute path to the remote SCP binary. + RemoteBinary string +} + +// Connect connects to the remote SSH server, returns error if it couldn't establish a session to the SSH server. +func (a *Client) Connect() error { + if a.Session != nil { + return nil + } + + client, err := ssh.Dial("tcp", a.Host, a.ClientConfig) + if err != nil { + return err + } + + a.Conn = client.Conn + a.Session, err = client.NewSession() + if err != nil { + return err + } + return nil +} + +// CopyFromFile copies the contents of an os.File to a remote location, it will get the length of the file by looking it up from the filesystem. +func (a *Client) CopyFromFile(ctx context.Context, file os.File, remotePath string, permissions string) error { + return a.CopyFromFilePassThru(ctx, file, remotePath, permissions, nil) +} + +// CopyFromFilePassThru copies the contents of an os.File to a remote location, it will get the length of the file by looking it up from the filesystem. +// Access copied bytes by providing a PassThru reader factory. +func (a *Client) CopyFromFilePassThru(ctx context.Context, file os.File, remotePath string, permissions string, passThru PassThru) error { + stat, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + return a.CopyPassThru(ctx, &file, remotePath, permissions, stat.Size(), passThru) +} + +// CopyFile copies the contents of an io.Reader to a remote location, the length is determined by reading the io.Reader until EOF +// if the file length in know in advance please use "Copy" instead. +func (a *Client) CopyFile(ctx context.Context, fileReader io.Reader, remotePath string, permissions string) error { + return a.CopyFilePassThru(ctx, fileReader, remotePath, permissions, nil) +} + +// CopyFilePassThru copies the contents of an io.Reader to a remote location, the length is determined by reading the io.Reader until EOF +// if the file length in know in advance please use "Copy" instead. +// Access copied bytes by providing a PassThru reader factory. +func (a *Client) CopyFilePassThru(ctx context.Context, fileReader io.Reader, remotePath string, permissions string, passThru PassThru) error { + contentsBytes, err := ioutil.ReadAll(fileReader) + if err != nil { + return fmt.Errorf("failed to read all data from reader: %w", err) + } + bytesReader := bytes.NewReader(contentsBytes) + + return a.CopyPassThru(ctx, bytesReader, remotePath, permissions, int64(len(contentsBytes)), passThru) +} + +// wait waits for the waitgroup for the specified max timeout. +// Returns true if waiting timed out. +func wait(wg *sync.WaitGroup, ctx context.Context) error { + c := make(chan struct{}) + go func() { + defer close(c) + wg.Wait() + }() + + select { + case <-c: + return nil + + case <-ctx.Done(): + return ctx.Err() + } +} + +// checkResponse checks the response it reads from the remote, and will return a single error in case +// of failure. +func checkResponse(r io.Reader) error { + response, err := ParseResponse(r) + if err != nil { + return err + } + + if response.IsFailure() { + return errors.New(response.GetMessage()) + } + + return nil + +} + +// Copy copies the contents of an io.Reader to a remote location. +func (a *Client) Copy(ctx context.Context, r io.Reader, remotePath string, permissions string, size int64) error { + return a.CopyPassThru(ctx, r, remotePath, permissions, size, nil) +} + +// CopyPassThru copies the contents of an io.Reader to a remote location. +// Access copied bytes by providing a PassThru reader factory +func (a *Client) CopyPassThru(ctx context.Context, r io.Reader, remotePath string, permissions string, size int64, passThru PassThru) error { + stdout, err := a.Session.StdoutPipe() + if err != nil { + return err + } + w, err := a.Session.StdinPipe() + if err != nil { + return err + } + defer w.Close() + + if passThru != nil { + r = passThru(r, size) + } + + filename := path.Base(remotePath) + + wg := sync.WaitGroup{} + wg.Add(2) + + errCh := make(chan error, 2) + + go func() { + defer wg.Done() + defer w.Close() + + _, err = fmt.Fprintln(w, "C"+permissions, size, filename) + if err != nil { + errCh <- err + return + } + + if err = checkResponse(stdout); err != nil { + errCh <- err + return + } + + _, err = io.Copy(w, r) + if err != nil { + errCh <- err + return + } + + _, err = fmt.Fprint(w, "\x00") + if err != nil { + errCh <- err + return + } + + if err = checkResponse(stdout); err != nil { + errCh <- err + return + } + }() + + go func() { + defer wg.Done() + err := a.Session.Run(fmt.Sprintf("%s -qt %q", a.RemoteBinary, remotePath)) + if err != nil { + errCh <- err + return + } + }() + + if a.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, a.Timeout) + defer cancel() + } + + if err := wait(&wg, ctx); err != nil { + return err + } + + close(errCh) + for err := range errCh { + if err != nil { + return err + } + } + return nil +} + +// CopyFromRemote copies a file from the remote to the local file given by the `file` +// parameter. Use `CopyFromRemotePassThru` if a more generic writer +// is desired instead of writing directly to a file on the file system.? +func (a *Client) CopyFromRemote(ctx context.Context, file *os.File, remotePath string) error { + return a.CopyFromRemotePassThru(ctx, file, remotePath, nil) +} + +// CopyFromRemotePassThru copies a file from the remote to the given writer. The passThru parameter can be used +// to keep track of progress and how many bytes that were download from the remote. +// `passThru` can be set to nil to disable this behaviour. +func (a *Client) CopyFromRemotePassThru(ctx context.Context, w io.Writer, remotePath string, passThru PassThru) error { + wg := sync.WaitGroup{} + errCh := make(chan error, 4) + + wg.Add(1) + go func() { + var err error + + defer func() { + // NOTE: this might send an already sent error another time, but since we only receive opne, this is fine. On the "happy-path" of this function, the error will be `nil` therefore completing the "err<-errCh" at the bottom of the function. + errCh <- err + // We must unblock the go routine first as we block on reading the channel later + wg.Done() + + }() + + r, err := a.Session.StdoutPipe() + if err != nil { + errCh <- err + return + } + + in, err := a.Session.StdinPipe() + if err != nil { + errCh <- err + return + } + defer in.Close() + + err = a.Session.Start(fmt.Sprintf("%s -f %q", a.RemoteBinary, remotePath)) + if err != nil { + errCh <- err + return + } + + err = Ack(in) + if err != nil { + errCh <- err + return + } + + res, err := ParseResponse(r) + if err != nil { + errCh <- err + return + } + if res.IsFailure() { + errCh <- errors.New(res.GetMessage()) + return + } + + infos, err := res.ParseFileInfos() + if err != nil { + errCh <- err + return + } + + err = Ack(in) + if err != nil { + errCh <- err + return + } + + if passThru != nil { + r = passThru(r, infos.Size) + } + + _, err = CopyN(w, r, infos.Size) + if err != nil { + errCh <- err + return + } + + err = Ack(in) + if err != nil { + errCh <- err + return + } + + err = a.Session.Wait() + if err != nil { + errCh <- err + return + } + }() + + if a.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, a.Timeout) + defer cancel() + } + + if err := wait(&wg, ctx); err != nil { + return err + } + finalErr := <-errCh + close(errCh) + return finalErr +} + +func (a *Client) Close() { + if a.Session != nil { + a.Session.Close() + } + if a.Conn != nil { + a.Conn.Close() + } +}
diff --git a/configurer.go b/configurer.go new file mode 100644 index 0000000..a9d8447 --- /dev/null +++ b/configurer.go
@@ -0,0 +1,82 @@ +/* Copyright (c) 2020 Bram Vandenbogaerde + * You may use, distribute or modify this code under the + * terms of the Mozilla Public License 2.0, which is distributed + * along with the source code. + */ + +package scp + +import ( + "time" + + "google3/third_party/golang/go_crypto/ssh/ssh" +) + +// ClientConfigurer a struct containing all the configuration options +// used by an scp client. +type ClientConfigurer struct { + host string + clientConfig *ssh.ClientConfig + session *ssh.Session + timeout time.Duration + remoteBinary string +} + +// NewConfigurer creates a new client configurer. +// It takes the required parameters: the host and the ssh.ClientConfig and +// returns a configurer populated with the default values for the optional +// parameters. +// +// These optional parameters can be set by using the methods provided on the +// ClientConfigurer struct. +func NewConfigurer(host string, config *ssh.ClientConfig) *ClientConfigurer { + return &ClientConfigurer{ + host: host, + clientConfig: config, + timeout: 0, // no timeout by default + remoteBinary: "scp", + } +} + +// RemoteBinary sets the path of the location of the remote scp binary +// Defaults to: /usr/bin/scp. +func (c *ClientConfigurer) RemoteBinary(path string) *ClientConfigurer { + c.remoteBinary = path + return c +} + +// Host alters the host of the client connects to. +func (c *ClientConfigurer) Host(host string) *ClientConfigurer { + c.host = host + return c +} + +// Timeout Changes the connection timeout. +// Defaults to one minute. +func (c *ClientConfigurer) Timeout(timeout time.Duration) *ClientConfigurer { + c.timeout = timeout + return c +} + +// ClientConfig alters the ssh.ClientConfig. +func (c *ClientConfigurer) ClientConfig(config *ssh.ClientConfig) *ClientConfigurer { + c.clientConfig = config + return c +} + +// Session alters the ssh.Session. +func (c *ClientConfigurer) Session(session *ssh.Session) *ClientConfigurer { + c.session = session + return c +} + +// Create builds a client with the configuration stored within the ClientConfigurer. +func (c *ClientConfigurer) Create() Client { + return Client{ + Host: c.host, + ClientConfig: c.clientConfig, + Timeout: c.timeout, + RemoteBinary: c.remoteBinary, + Session: c.session, + } +}
diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a8c8124 --- /dev/null +++ b/go.mod
@@ -0,0 +1,8 @@ +module github.com/bramvdbogaerde/go-scp + +go 1.13 + +require ( + golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a + golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea // indirect +)
diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..45c115f --- /dev/null +++ b/go.sum
@@ -0,0 +1,10 @@ +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI= +golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
diff --git a/protocol.go b/protocol.go new file mode 100644 index 0000000..ca19d71 --- /dev/null +++ b/protocol.go
@@ -0,0 +1,125 @@ +/* Copyright (c) 2021 Bram Vandenbogaerde And Contributors + * You may use, distribute or modify this code under the + * terms of the Mozilla Public License 2.0, which is distributed + * along with the source code. + */ + +package scp + +import ( + "bufio" + "errors" + "io" + "strconv" + "strings" +) + +type ResponseType = uint8 + +const ( + Ok ResponseType = 0 + Warning ResponseType = 1 + Error ResponseType = 2 +) + +// Response represent a response from the SCP command. +// There are tree types of responses that the remote can send back: +// ok, warning and error +// +// The difference between warning and error is that the connection is not closed by the remote, +// however, a warning can indicate a file transfer failure (such as invalid destination directory) +// and such be handled as such. +// +// All responses except for the `Ok` type always have a message (although these can be empty) +// +// The remote sends a confirmation after every SCP command, because a failure can occur after every +// command, the response should be read and checked after sending them. +type Response struct { + Type ResponseType + Message string +} + +// ParseResponse reads from the given reader (assuming it is the output of the remote) and parses it into a Response structure. +func ParseResponse(reader io.Reader) (Response, error) { + buffer := make([]uint8, 1) + _, err := reader.Read(buffer) + if err != nil { + return Response{}, err + } + + responseType := buffer[0] + message := "" + if responseType > 0 { + bufferedReader := bufio.NewReader(reader) + message, err = bufferedReader.ReadString('\n') + if err != nil { + return Response{}, err + } + } + + return Response{responseType, message}, nil +} + +func (r *Response) IsOk() bool { + return r.Type == Ok +} + +func (r *Response) IsWarning() bool { + return r.Type == Warning +} + +// IsError returns true when the remote responded with an error. +func (r *Response) IsError() bool { + return r.Type == Error +} + +// IsFailure returns true when the remote answered with a warning or an error. +func (r *Response) IsFailure() bool { + return r.IsWarning() || r.IsError() +} + +// GetMessage returns the message the remote sent back. +func (r *Response) GetMessage() string { + return r.Message +} + +type FileInfos struct { + Message string + Filename string + Permissions string + Size int64 +} + +func (r *Response) ParseFileInfos() (*FileInfos, error) { + message := strings.ReplaceAll(r.Message, "\n", "") + parts := strings.Split(message, " ") + if len(parts) < 3 { + return nil, errors.New("unable to parse message as file infos") + } + + size, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, err + } + + return &FileInfos{ + Message: r.Message, + Permissions: parts[0], + Size: int64(size), + Filename: parts[2], + }, nil +} + +// Ack writes an `Ack` message to the remote, does not await its response, a seperate call to ParseResponse is +// therefore required to check if the acknowledgement succeeded. +func Ack(writer io.Writer) error { + var msg = []byte{0} + n, err := writer.Write(msg) + if err != nil { + return err + } + if n < len(msg) { + return errors.New("failed to write ack buffer") + } + return nil +}
diff --git a/scp.go b/scp.go new file mode 100644 index 0000000..d86fb35 --- /dev/null +++ b/scp.go
@@ -0,0 +1,45 @@ +/* Copyright (c) 2021 Bram Vandenbogaerde + * You may use, distribute or modify this code under the + * terms of the Mozilla Public License 2.0, which is distributed + * along with the source code. + */ + +// Package scp. +// Simple scp package to copy files over SSH. +package scp + +import ( + "time" + + "google3/third_party/golang/go_crypto/ssh/ssh" +) + +// NewClient returns a new scp.Client with provided host and ssh.clientConfig. +func NewClient(host string, config *ssh.ClientConfig) Client { + return NewConfigurer(host, config).Create() +} + +// NewClientWithTimeout returns a new scp.Client with provides host, ssh.ClientConfig and timeout. +// Deprecated: provide meaningful context to each "Copy*" function instead. +func NewClientWithTimeout(host string, config *ssh.ClientConfig, timeout time.Duration) Client { + return NewConfigurer(host, config).Timeout(timeout).Create() +} + +// NewClientBySSH returns a new scp.Client using an already existing established SSH connection. +func NewClientBySSH(ssh *ssh.Client) (Client, error) { + session, err := ssh.NewSession() + if err != nil { + return Client{}, err + } + return NewConfigurer("", nil).Session(session).Create(), nil +} + +// NewClientBySSHWithTimeout same as NewClientWithTimeout but uses an existing SSH client. +// Deprecated: provide meaningful context to each "Copy*" function instead. +func NewClientBySSHWithTimeout(ssh *ssh.Client, timeout time.Duration) (Client, error) { + session, err := ssh.NewSession() + if err != nil { + return Client{}, err + } + return NewConfigurer("", nil).Session(session).Timeout(timeout).Create(), nil +}
diff --git a/tests/basic_test.go b/tests/basic_test.go new file mode 100644 index 0000000..66cce46 --- /dev/null +++ b/tests/basic_test.go
@@ -0,0 +1,191 @@ +package tests_test + +import ( + "context" + "io/ioutil" + "os" + "strings" + "testing" + "time" + + "google3/third_party/golang/github_com/bramvdbogaerde/go_scp/v/v1/auth/auth" + "google3/third_party/golang/github_com/bramvdbogaerde/go_scp/v/v1/scp" + "google3/third_party/golang/go_crypto/ssh/ssh" +) + +func establishConnection(t *testing.T) scp.Client { + // Use SSH key authentication from the auth package. + // During testing we ignore the host key, don't to that when you use this. + clientConfig, _ := auth.PasswordKey("bram", "test", ssh.InsecureIgnoreHostKey()) + + // Create a new SCP client. + client := scp.NewClient("127.0.0.1:2244", &clientConfig) + + // Connect to the remote server. + err := client.Connect() + if err != nil { + t.Fatalf("Couldn't establish a connection to the remote server: %s", err) + } + return client +} + +// TestCopy tests the basic functionality of copying a file to the remote +// destination. +// +// It assumes that a Docker container is running an SSH server at port 2244 +// that is using password authentication. It also assumes that the directory +// /data is writable within that container and is mapped to ./tmp/ within the +// directory the test is run from. +func TestCopy(t *testing.T) { + client := establishConnection(t) + defer client.Close() + + // Open a file we can transfer to the remote container. + f, _ := os.Open("./data/upload_file.txt") + defer f.Close() + + // Create a file name with exotic characters and spaces in them. + // If this test works for this, simpler files should not be a problem. + filename := "Exöt1ç uploaded file.txt" + + // Finaly, copy the file over. + // Usage: CopyFile(fileReader, remotePath, permission). + err := client.CopyFile(context.Background(), f, "/data/"+filename, "0777") + if err != nil { + t.Errorf("Error while copying file: %s", err) + } + + // Read what the receiver have written to disk. + content, err := ioutil.ReadFile("./tmp/" + filename) + if err != nil { + t.Errorf("Result file could not be read: %s", err) + } + + text := string(content) + expected := "It Works\n" + if strings.Compare(text, expected) != 0 { + t.Errorf("Got different text than expected, expected %q got, %q", expected, text) + } +} + +// TestDownloadFile tests the basic functionality of copying a file from the +// remote destination. +// +// It assumes that a Docker container is running an SSH server at port 2244 +// that is using password authentication. It also assumes that the directory +// /data is writable within that container and is mapped to ./tmp/ within the +// directory the test is run from. +func TestDownloadFile(t *testing.T) { + client := establishConnection(t) + defer client.Close() + + // Open a file we can transfer to the remote container. + f, _ := os.Open("./data/input.txt") + defer f.Close() + + // Create a local file to write to. + f, err := os.OpenFile("./tmp/output.txt", os.O_RDWR|os.O_CREATE, 0777) + if err != nil { + t.Errorf("Couldn't open the output file") + } + defer f.Close() + + // Use a file name with exotic characters and spaces in them. + // If this test works for this, simpler files should not be a problem. + err = client.CopyFromRemote(context.Background(), f, "/input/Exöt1ç download file.txt.txt") + if err != nil { + t.Errorf("Copy failed from remote: %s", err.Error()) + } + + content, err := ioutil.ReadFile("./tmp/output.txt") + if err != nil { + t.Errorf("Result file could not be read: %s", err) + } + + text := string(content) + expected := "It works for download!\n" + if strings.Compare(text, expected) != 0 { + t.Errorf("Got different text than expected, expected %q got, %q", expected, text) + } +} + +// TestTimeoutDownload tests that a timeout error is produced if the file is not copied in the given +// amount of time. +func TestTimeoutDownload(t *testing.T) { + client := establishConnection(t) + defer client.Close() + client.Timeout = 1 * time.Millisecond + + // Open a file we can transfer to the remote container. + f, _ := os.Open("./data/upload_file.txt") + defer f.Close() + + // Create a file name with exotic characters and spaces in them. + // If this test works for this, simpler files should not be a problem. + filename := "Exöt1ç uploaded file.txt" + + err := client.CopyFile(context.Background(), f, "/data/"+filename, "0777") + if err != context.DeadlineExceeded { + t.Errorf("Expected a timeout error but got succeeded without error") + } +} + +// TestContextCancelDownload tests that a a copy is immediately cancelled if we call context.cancel() +func TestContextCancelDownload(t *testing.T) { + client := establishConnection(t) + defer client.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // Open a file we can transfer to the remote container. + f, _ := os.Open("./data/upload_file.txt") + defer f.Close() + + // Create a file name with exotic characters and spaces in them. + // If this test works for this, simpler files should not be a problem. + filename := "Exöt1ç uploaded file.txt" + + err := client.CopyFile(ctx, f, "/data/"+filename, "0777") + if err != context.Canceled { + t.Errorf("Expected a canceled error but transfer succeeded without error") + } +} + +func TestDownloadBadLocalFilePermissions(t *testing.T) { + client := establishConnection(t) + defer client.Close() + + // Create a file with local bad permissions + // This happens only on Linux + f, err := os.OpenFile("./tmp/output_bdf.txt", os.O_CREATE, 0644) + if err != nil { + t.Error("Couldn't open the output file", err.Error()) + } + defer f.Close() + + // This should not timeout and throw an error + err = client.CopyFromRemote(context.Background(), f, "/input/Exöt1ç download file.txt.txt") + if err == nil { + t.Errorf("Expected error thrown. Got nil") + } +} + +func TestFileNotFound(t *testing.T) { + client := establishConnection(t) + defer client.Close() + + f, err := os.OpenFile("./tmp/output_fnf.txt", os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + t.Error("Couldn't open the output file", err.Error()) + } + // This should throw file not found on remote + err = client.CopyFromRemote(context.Background(), f, "/input/no_such_file.txt") + if err == nil { + t.Errorf("Expected error thrown. Got nil") + } + expected := "scp: /input/no_such_file.txt: No such file or directory\n" + if err.Error() != expected { + t.Errorf("Expected %v, got %v", expected, err.Error()) + } +}
diff --git "a/tests/data/Ex\303\266t1\303\247_download_file.txt.txt" "b/tests/data/Ex\303\266t1\303\247_download_file.txt.txt" new file mode 100644 index 0000000..7f191e8 --- /dev/null +++ "b/tests/data/Ex\303\266t1\303\247_download_file.txt.txt"
@@ -0,0 +1 @@ +It works for download!
diff --git a/tests/data/upload_file.txt b/tests/data/upload_file.txt new file mode 100644 index 0000000..2bfa722 --- /dev/null +++ b/tests/data/upload_file.txt
@@ -0,0 +1 @@ +It Works
diff --git a/tests/entrypoint_d/setpasswd.sh b/tests/entrypoint_d/setpasswd.sh new file mode 100644 index 0000000..dc6a15d --- /dev/null +++ b/tests/entrypoint_d/setpasswd.sh
@@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +echo 'bram:$6$9CLVhdvYPsRYjJZJ$UoJbmXrl6F.kL1u1Mnio6gRgKAB7HG0eSeATa.HMu7liRGAINTicM0Ql5/AONVwKXsrA0BbMOOr3BHrODnP2s0' | chpasswd --encrypted
diff --git a/tests/run_all.sh b/tests/run_all.sh new file mode 100644 index 0000000..834c78e --- /dev/null +++ b/tests/run_all.sh
@@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +rm tmp/* + +echo "Running from $(pwd)" + +echo "Starting docker containers" + +docker run -d \ + --name go-scp-test \ + -p 2244:22 \ + -e SSH_USERS=bram:1000:1000 \ + -e SSH_ENABLE_PASSWORD_AUTH=true \ + -v $(pwd)/tmp:/data/ \ + -v $(pwd)/data:/input \ + -v $(pwd)/entrypoint.d/:/etc/entrypoint.d/ \ + panubo/sshd + +sleep 5 + +echo "Running tests" +go test -v + +echo "Tearing down docker containers" +docker stop go-scp-test +docker rm go-scp-test + +echo "Cleaning up" +rm tmp/*
diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..447775f --- /dev/null +++ b/utils.go
@@ -0,0 +1,25 @@ +/* Copyright (c) 2021 Bram Vandenbogaerde And Contributors + * You may use, distribute or modify this code under the + * terms of the Mozilla Public License 2.0, which is distributed + * along with the source code. + */ + +package scp + +import "io" + +// CopyN an adaptation of io.CopyN that keeps reading if it did not return +// a sufficient amount of bytes. +func CopyN(writer io.Writer, src io.Reader, size int64) (int64, error) { + var total int64 + total = 0 + for total < size { + n, err := io.CopyN(writer, src, size) + if err != nil { + return 0, err + } + total += n + } + + return total, nil +}