From fe23ad2a03347255c4054c198230ffb807b7cdeb Mon Sep 17 00:00:00 2001 From: Ilya Brin <464157+ilyabrin@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:48:27 +0300 Subject: [PATCH 1/4] fix: send request bodies with Content-Length Wrapping every body in io.LimitReader hid its length, so PUT and PATCH bodies went out chunked, and the API read them as empty. UpdateMetadata never stored anything. Bodies of known length now go as they are. The test transport kept losing the length too, which is why no test noticed. --- client.go | 12 ++++++++++-- client_test.go | 2 ++ request_body_test.go | 27 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 request_body_test.go diff --git a/client.go b/client.go index af06f86..c9cf6a7 100644 --- a/client.go +++ b/client.go @@ -1,6 +1,7 @@ package disk import ( + "bytes" "context" "crypto/tls" "encoding/json" @@ -182,8 +183,15 @@ func (c *Client) doRequest(ctx context.Context, method HttpMethod, resource stri if method == GET || method == DELETE { body = nil } else if data != nil { - // Limit request body size to prevent memory exhaustion - body = io.LimitReader(data, 100*1024*1024) // 100MB limit + switch data.(type) { + case *bytes.Reader, *bytes.Buffer, *strings.Reader: + // The length is known, so the request carries Content-Length. + // Wrapping these would send the body chunked, which the API + // ignores: PATCH and PUT bodies arrived empty. + default: + // Limit request body size to prevent memory exhaustion + body = io.LimitReader(data, 100*1024*1024) // 100MB limit + } } requestURL := c.baseURL() + resource diff --git a/client_test.go b/client_test.go index aa41341..e543e41 100644 --- a/client_test.go +++ b/client_test.go @@ -26,6 +26,8 @@ func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { if err != nil { return nil, err } + // Keep the length, so tests see the body as the real server would. + testReq.ContentLength = req.ContentLength // Copy headers testReq.Header = req.Header.Clone() diff --git a/request_body_test.go b/request_body_test.go new file mode 100644 index 0000000..3f6d87c --- /dev/null +++ b/request_body_test.go @@ -0,0 +1,27 @@ +package disk + +import ( + "context" + "net/http" + "testing" +) + +// Bodies of known length must go with Content-Length: sent chunked, the API +// read PATCH and PUT bodies as empty. +func TestJSONBodiesCarryContentLength(t *testing.T) { + var length int64 + var chunked bool + client := mockedHttpClient(func(w http.ResponseWriter, r *http.Request) { + length = r.ContentLength + chunked = len(r.TransferEncoding) > 0 + _, _ = w.Write([]byte(`{"name": "a", "path": "disk:/a", "type": "file"}`)) + }) + + props := map[string]map[string]string{"custom_properties": {"k": "v"}} + if _, errResp := client.UpdateMetadata(context.Background(), "disk:/a", props); errResp != nil { + t.Fatal(errResp) + } + if length <= 0 || chunked { + t.Errorf("Content-Length = %d, chunked = %v; want a known length", length, chunked) + } +} From 2989bb8d1d1d48020cf657837a34f65382bdb759 Mon Sep 17 00:00:00 2001 From: Ilya Brin <464157+ilyabrin@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:48:27 +0300 Subject: [PATCH 2/4] fix: batch copy and move honour Overwrite and wait only on operations BatchCopyMoveOptions.Overwrite was ignored. And WaitForBatchOperation polled every returned link as an operation, but a copy that finishes at once returns a link to the file, so waiting failed with "Operation not found". --- api_coverage_test.go | 38 ++++++++++++++++++++++++++++++++++++++ batch.go | 15 +++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/api_coverage_test.go b/api_coverage_test.go index b40d07e..3ae7175 100644 --- a/api_coverage_test.go +++ b/api_coverage_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/url" "testing" + "time" ) // request is what a test server saw. @@ -189,3 +190,40 @@ func TestCoordinateToleratesOddShapes(t *testing.T) { } } } + +func TestBatchCopyAndMoveHonourOverwrite(t *testing.T) { + opts := &BatchCopyMoveOptions{Overwrite: true} + ops := map[string]string{"disk:/a": "disk:/b"} + + client, got := recordingClient(http.StatusCreated, linkJSON) + if _, err := client.BatchCopyFiles(context.Background(), ops, opts); err != nil { + t.Fatal(err) + } + expectQuery(t, got, http.MethodPost, "/v1/disk/resources/copy", map[string]string{"overwrite": "true"}) + + client, got = recordingClient(http.StatusCreated, linkJSON) + if _, err := client.BatchMoveFiles(context.Background(), ops, opts); err != nil { + t.Fatal(err) + } + expectQuery(t, got, http.MethodPost, "/v1/disk/resources/move", map[string]string{"overwrite": "true"}) +} + +// A copy that finished at once returns a link to the copied file, not to an +// operation; waiting must not try to poll it. +func TestWaitForBatchOperationSkipsFinishedResults(t *testing.T) { + client, got := recordingClient(http.StatusCreated, + `{"href": "https://cloud-api.yandex.net/v1/disk/resources?path=disk%3A%2Fb", "method": "GET"}`) + status, err := client.BatchCopyFiles(context.Background(), map[string]string{"disk:/a": "disk:/b"}, nil) + if err != nil { + t.Fatal(err) + } + got.path = "" + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := client.WaitForBatchOperation(ctx, status, 10*time.Millisecond); err != nil { + t.Fatalf("wait: %v", err) + } + if got.path != "" { + t.Errorf("waiting polled %s", got.path) + } +} diff --git a/batch.go b/batch.go index 56e41ad..93f7a69 100644 --- a/batch.go +++ b/batch.go @@ -233,7 +233,7 @@ func (c *Client) BatchCopyFiles(ctx context.Context, operations map[string]strin } // Perform the copy operation - link, errResp := c.CopyResource(opCtx, from, to) + link, errResp := c.CopyResourceWithOptions(opCtx, from, to, &CopyMoveOptions{Overwrite: options.Overwrite}) result.Duration = time.Since(startTime) if errResp != nil { @@ -346,7 +346,7 @@ func (c *Client) BatchMoveFiles(ctx context.Context, operations map[string]strin } // Perform the move operation - link, errResp := c.MoveResource(opCtx, from, to) + link, errResp := c.MoveResourceWithOptions(opCtx, from, to, &CopyMoveOptions{Overwrite: options.Overwrite}) result.Duration = time.Since(startTime) if errResp != nil { @@ -693,7 +693,7 @@ func (c *Client) WaitForBatchOperation(ctx context.Context, status *BatchOperati // Check if there are any async operations (operations that returned Links) asyncOps := 0 for _, result := range status.Results { - if result != nil && result.Link != nil { + if result != nil && isOperationLink(result.Link) { asyncOps++ } } @@ -715,7 +715,7 @@ func (c *Client) WaitForBatchOperation(ctx context.Context, status *BatchOperati case <-ticker.C: completed := 0 for _, result := range status.Results { - if result == nil || result.Link == nil { + if result == nil || !isOperationLink(result.Link) { continue } @@ -815,3 +815,10 @@ func (c *Client) RetryFailedOperations(ctx context.Context, status *BatchOperati return retryStatus, nil } + +// isOperationLink reports whether link points to a background operation. +// An operation that finished at once returns a link to its result instead, +// such as the copied file, and there is nothing to wait for. +func isOperationLink(link *Link) bool { + return link != nil && strings.Contains(link.Href, "/operations/") +} From 3d94053701e14566033e7ade075b6677f31a25df Mon Sep 17 00:00:00 2001 From: Ilya Brin <464157+ilyabrin@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:48:28 +0300 Subject: [PATCH 3/4] feat: protect public links with a password or an expiry Built on what the live API does, which differs from its reference: settings sent with publishing are ignored, so they follow in a PATCH; the expiry is a Unix time, not a duration; and an access needs a type. PublishResourceWithSettings unpublishes again if the settings fail, so a link meant to be protected is never left open. Address access is for Yandex 360 for business, and forbidding downloads is not available through the public API; the docs say both. --- public_settings.go | 168 ++++++++++++++++++++++++++++++++++++++++ public_settings_test.go | 125 ++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 public_settings.go create mode 100644 public_settings_test.go diff --git a/public_settings.go b/public_settings.go new file mode 100644 index 0000000..136e585 --- /dev/null +++ b/public_settings.go @@ -0,0 +1,168 @@ +package disk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// Rights a public link can grant, for [PublicAccess.Rights]. +const ( + RightRead = "read" + RightReadWithoutDownload = "read_without_download" + RightReadWithPassword = "read_with_password" + RightReadWithPasswordWithoutDownload = "read_with_password_without_download" + RightWrite = "write" +) + +// Audiences of a public link, for [PublicAccess.Macros]. +const ( + MacroAll = "all" // anyone with the link + MacroEmployees = "employees" // only people in the organization OrgID +) + +// PublicSettings protect a public link. Pass them to +// [Client.PublishResourceWithSettings] or [Client.UpdatePublicSettings]. +// +// Password and ExpiresAt work on personal accounts too; this was checked +// against the live API, where the official reference differs in places. +// Forbidding downloads is not possible through the public API: the web +// interface uses an internal call for it. +type PublicSettings struct { + // Password, when set, must be entered to open the link. + Password string + // ExpiresAt, when set, is the moment the link stops working. + ExpiresAt time.Time + // Accesses grant address access in Yandex 360 for business. Leave them + // empty on a personal account: there they close the link to everyone. + Accesses []PublicAccess +} + +// PublicAccess grants rights over a public link to an audience in Yandex 360 +// for business. +type PublicAccess struct { + // Macros name the audience: [MacroAll] or [MacroEmployees]. + Macros []string `json:"macros,omitempty"` + // OrgID is the organization meant by [MacroEmployees]. + OrgID int64 `json:"org_id,omitempty"` + // Type is "macro" for an audience given by Macros, which is filled in + // when left empty, or "user", "group", "department" or "outer_user" + // together with ID. + Type string `json:"type"` + ID int64 `json:"id,omitempty"` + // Rights are what the audience may do, such as [RightRead]. + Rights []string `json:"rights"` +} + +func (s *PublicSettings) empty() bool { + return s == nil || (s.Password == "" && s.ExpiresAt.IsZero() && len(s.Accesses) == 0) +} + +// body is the PATCH payload. The API takes the expiry as a Unix time in +// seconds, not as a duration as the reference says, and wants "type" on +// every access although the reference examples leave it out. +func (s *PublicSettings) body() ([]byte, error) { + payload := map[string]any{} + if s.Password != "" { + payload["password"] = s.Password + } + if !s.ExpiresAt.IsZero() { + payload["available_until"] = s.ExpiresAt.Unix() + } + if len(s.Accesses) > 0 { + accesses := make([]PublicAccess, len(s.Accesses)) + copy(accesses, s.Accesses) + for i := range accesses { + if accesses[i].Type == "" && len(accesses[i].Macros) > 0 { + accesses[i].Type = "macro" + } + } + payload["accesses"] = accesses + } + return json.Marshal(payload) +} + +// PublishResourceWithSettings publishes the file or folder at path and +// protects its link with settings. The API ignores settings sent along with +// publishing, so this publishes first and then applies them. If applying +// them fails, the resource is unpublished again rather than left open, and +// the error is returned. With empty settings it is the same as +// [Client.PublishResource]. +func (c *Client) PublishResourceWithSettings(ctx context.Context, path string, settings *PublicSettings) (*Link, *ErrorResponse) { + link, errResp := c.PublishResource(ctx, path) + if errResp != nil || settings.empty() { + return link, errResp + } + if errResp := c.UpdatePublicSettings(ctx, path, settings); errResp != nil { + _, _ = c.UnpublishResource(ctx, path) + return nil, errResp + } + return link, nil +} + +// UpdatePublicSettings changes the protection of the already published file +// or folder at path. Settings left empty are not changed. +func (c *Client) UpdatePublicSettings(ctx context.Context, path string, settings *PublicSettings) *ErrorResponse { + if len(path) < 1 { + return &ErrorResponse{Error: "path cannot be empty"} + } + if settings.empty() { + return &ErrorResponse{Error: "settings cannot be empty"} + } + + body, err := settings.body() + if err != nil { + return &ErrorResponse{Error: fmt.Sprintf("failed to encode settings: %v", err)} + } + + query := url.Values{} + query.Set("path", path) + + resp, err := c.doRequest(ctx, PATCH, "public/resources/public-settings?"+query.Encode(), bytes.NewReader(body)) + if err != nil { + return &ErrorResponse{Error: fmt.Sprintf("request failed: %v", err)} + } + defer func() { _ = resp.Body.Close() }() + + errResp, err := c.handleResponse(resp, []int{http.StatusOK, http.StatusNoContent}) + if errResp != nil { + return errResp + } + if err != nil { + return &ErrorResponse{Error: err.Error(), StatusCode: resp.StatusCode} + } + return nil +} + +// PublicSettingsInfo is what the API reports about a link's protection. +type PublicSettingsInfo struct { + // ExpiresAt is when the link stops working, or zero for never. + ExpiresAt time.Time +} + +// GetPublicSettings reads the protection of the published file or folder at +// path. The API reports only the expiry; whether a password is set cannot +// be read back. +func (c *Client) GetPublicSettings(ctx context.Context, path string) (*PublicSettingsInfo, *ErrorResponse) { + if len(path) < 1 { + return nil, &ErrorResponse{Error: "path cannot be empty"} + } + query := url.Values{} + query.Set("path", path) + + raw, errResp := requestJSON[struct { + AvailableUntil *int64 `json:"available_until"` + }](ctx, c, GET, "public/resources/public-settings?"+query.Encode(), nil) + if errResp != nil { + return nil, errResp + } + info := &PublicSettingsInfo{} + if raw.AvailableUntil != nil && *raw.AvailableUntil > 0 { + info.ExpiresAt = time.Unix(*raw.AvailableUntil, 0) + } + return info, nil +} diff --git a/public_settings_test.go b/public_settings_test.go new file mode 100644 index 0000000..e65ef24 --- /dev/null +++ b/public_settings_test.go @@ -0,0 +1,125 @@ +package disk + +import ( + "context" + "encoding/json" + "io" + "net/http" + "sync" + "testing" + "time" +) + +// settingsServer records the requests of a publish-with-settings flow and +// answers the PATCH with patchStatus. +type settingsServer struct { + mu sync.Mutex + requests []string + patch map[string]any +} + +func (s *settingsServer) client(patchStatus int) *Client { + return mockedHttpClient(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.requests = append(s.requests, r.Method+" "+r.URL.Path) + switch r.Method { + case http.MethodPatch: + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &s.patch) + w.WriteHeader(patchStatus) + if patchStatus != http.StatusOK { + _, _ = w.Write([]byte(`{"error": "FieldValidationError", "description": "Bad."}`)) + } + default: + _, _ = w.Write([]byte(`{"href": "https://cloud-api.yandex.net/v1/disk/resources?path=disk%3A%2Fa", "method": "GET"}`)) + } + }) +} + +func TestPublishWithSettingsPublishesThenPatches(t *testing.T) { + var srv settingsServer + expires := time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) + _, errResp := srv.client(http.StatusOK).PublishResourceWithSettings(context.Background(), "disk:/a", + &PublicSettings{Password: "secret", ExpiresAt: expires}) + if errResp != nil { + t.Fatal(errResp) + } + want := []string{"PUT /v1/disk/resources/publish", "PATCH /v1/disk/public/resources/public-settings"} + if len(srv.requests) != 2 || srv.requests[0] != want[0] || srv.requests[1] != want[1] { + t.Errorf("requests = %v, want %v", srv.requests, want) + } + // The API takes the expiry as a Unix time, not as a duration. + if srv.patch["password"] != "secret" || srv.patch["available_until"] != float64(expires.Unix()) { + t.Errorf("patch body = %v", srv.patch) + } + if _, ok := srv.patch["accesses"]; ok { + t.Error("accesses must not be sent unless asked for: they close the link on personal accounts") + } +} + +func TestPublishWithSettingsUnpublishesWhenTheyFail(t *testing.T) { + var srv settingsServer + _, errResp := srv.client(http.StatusBadRequest).PublishResourceWithSettings(context.Background(), "disk:/a", + &PublicSettings{Password: "secret"}) + if errResp == nil { + t.Fatal("a failed PATCH must be reported") + } + last := srv.requests[len(srv.requests)-1] + if last != "PUT /v1/disk/resources/unpublish" { + t.Errorf("requests = %v; the link must not stay open without its protection", srv.requests) + } +} + +func TestPublishWithEmptySettingsIsAPlainPublish(t *testing.T) { + var srv settingsServer + if _, errResp := srv.client(http.StatusOK).PublishResourceWithSettings(context.Background(), "disk:/a", &PublicSettings{}); errResp != nil { + t.Fatal(errResp) + } + if len(srv.requests) != 1 { + t.Errorf("requests = %v, want only the publish", srv.requests) + } +} + +func TestAccessesGetTheirType(t *testing.T) { + body, err := (&PublicSettings{Accesses: []PublicAccess{{Macros: []string{MacroEmployees}, OrgID: 7, Rights: []string{RightRead}}}}).body() + if err != nil { + t.Fatal(err) + } + var got struct { + Accesses []map[string]any `json:"accesses"` + } + _ = json.Unmarshal(body, &got) + if len(got.Accesses) != 1 || got.Accesses[0]["type"] != "macro" { + t.Errorf("body = %s; the API rejects an access without a type", body) + } +} + +func TestUpdatePublicSettingsRejectsEmptySettings(t *testing.T) { + var srv settingsServer + if errResp := srv.client(http.StatusOK).UpdatePublicSettings(context.Background(), "disk:/a", &PublicSettings{}); errResp == nil { + t.Error("empty settings must be refused before a request is made") + } + if len(srv.requests) != 0 { + t.Errorf("requests = %v", srv.requests) + } +} + +func TestGetPublicSettingsReadsTheExpiry(t *testing.T) { + for body, want := range map[string]int64{`{"available_until": 1791419390}`: 1791419390, `{"available_until": null}`: 0} { + client := mockedHttpClient(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + }) + info, errResp := client.GetPublicSettings(context.Background(), "disk:/a") + if errResp != nil { + t.Fatal(errResp) + } + got := int64(0) + if !info.ExpiresAt.IsZero() { + got = info.ExpiresAt.Unix() + } + if got != want { + t.Errorf("%s: expires %d, want %d", body, got, want) + } + } +} From 40b59f58faa7a8acecfe924faecac84b55a55031 Mon Sep 17 00:00:00 2001 From: Ilya Brin <464157+ilyabrin@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:48:28 +0300 Subject: [PATCH 4/4] test: an integration suite against the real API Behind the integration build tag. It works in a new folder it deletes for good at the end, and a guard in the HTTP transport refuses any change outside it, any trash item it did not create, and emptying the whole trash. It checks link protection as a stranger without a token would. CONTRIBUTING explains how to run it. --- CONTRIBUTING.md | 18 ++ integration_test.go | 569 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 587 insertions(+) create mode 100644 integration_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5709b7f..9d90011 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,24 @@ fix. Tests talk to an `httptest` server rather than to Yandex; look at Keep tests deterministic. If one needs to wait, give it a timeout measured in milliseconds, not seconds, so the suite stays fast. +### Against the real API + +`integration_test.go` checks the library against Yandex itself. It is behind +a build tag, so `go test ./...` skips it. Run it with a token of an account +you can spare: + +```sh +YANDEX_DISK_ACCESS_TOKEN=... go test -tags integration -run Integration -v ./ +``` + +It takes about two minutes and needs a few megabytes. Everything it creates +lives in a new `disk:/disk-it--