From aba403fa79ea8f4fc9a9e2cbe4c333412e0eb257 Mon Sep 17 00:00:00 2001 From: Christian De Santis <41491267+christiandesantis@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:17:13 -0400 Subject: [PATCH] iclouddrive: fix uploads into an app container failing with 412 - fixes #9729 Writing any file into a third-party app container - the Obsidian, Pages or Shortcuts folders that iCloud Drive shows alongside your own - failed with HTTP error 412 (412 Precondition Failed) returned body: "{ ... \"error_code\" : \"VALIDATING_REFERENCE_ERROR\", \"reason\" : \"Request has out of order children to be chained but the parents were missing\" }" Reading from those paths worked, and so did creating directories in them, so the failure looked like a missing parent when the parent was plainly there. Items in an app container live in a different zone from ordinary iCloud Drive folders: a folder under Documents has a drive ID like FOLDER::com.apple.CloudDocs::, while the Obsidian container has FOLDER::iCloud.md.obsidian::documents#o2v. DownloadFile already accounts for this - it deconstructs the item's own ID and addresses the zone it finds - but CreateUpload and UpdateFile hardcoded defaultZone, and UpdateFile built the resulting Drivewsid with a hardcoded com.apple.CloudDocs as well. So rclone asked Apple to chain the new document to a parent in com.apple.CloudDocs while the parent lived in iCloud.md.obsidian. The parent really was missing from the zone being addressed, which is what the error said. Take the zone from the parent's drive ID instead, the same way the download path does, and build the new item's ID with ConstructDriveID. Uploads outside an app container are unaffected: their parents are in com.apple.CloudDocs, so the derived zone is the value that was previously hardcoded. Verified against a real remote: files now upload into an Obsidian vault inside the container and read back correctly with an unpatched binary afterwards. --- backend/iclouddrive/api/drive.go | 22 +++++++++++++----- backend/iclouddrive/api/drive_test.go | 32 +++++++++++++++++++++++++++ backend/iclouddrive/iclouddrive.go | 6 ++--- 3 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 backend/iclouddrive/api/drive_test.go diff --git a/backend/iclouddrive/api/drive.go b/backend/iclouddrive/api/drive.go index 1fe9d907a..73d23e39a 100644 --- a/backend/iclouddrive/api/drive.go +++ b/backend/iclouddrive/api/drive.go @@ -498,7 +498,7 @@ func (d *DriveService) CopyDocByItemID(ctx context.Context, itemID string) (*Dri } // CreateUpload creates an url for an upload. -func (d *DriveService) CreateUpload(ctx context.Context, size int64, name string) (*UploadResponse, *http.Response, error) { +func (d *DriveService) CreateUpload(ctx context.Context, size int64, name, zone string) (*UploadResponse, *http.Response, error) { // first we need to request an upload url values := map[string]any{ "filename": name, @@ -513,7 +513,7 @@ func (d *DriveService) CreateUpload(ctx context.Context, size int64, name string opts := rest.Opts{ Method: "POST", - Path: "/ws/" + defaultZone + "/upload/web", + Path: "/ws/" + zone + "/upload/web", ExtraHeaders: d.icloud.Session.GetHeaders(map[string]string{}), RootURL: d.docsEndpoint, Body: body, @@ -552,14 +552,14 @@ func (d *DriveService) Upload(ctx context.Context, in io.Reader, size int64, nam // ctx: the context.Context object for the request. // r: a pointer to the UpdateFileInfo struct containing the information for the file update. // Returns a pointer to the DriveItem struct representing the updated file, the http.Response object, and an error if any. -func (d *DriveService) UpdateFile(ctx context.Context, r *UpdateFileInfo) (*DriveItem, *http.Response, error) { +func (d *DriveService) UpdateFile(ctx context.Context, r *UpdateFileInfo, zone string) (*DriveItem, *http.Response, error) { body, err := IntoReader(r) if err != nil { return nil, nil, err } opts := rest.Opts{ Method: "POST", - Path: "/ws/" + defaultZone + "/update/documents", + Path: "/ws/" + zone + "/update/documents", ExtraHeaders: d.icloud.Session.GetHeaders(map[string]string{}), RootURL: d.docsEndpoint, Body: body, @@ -572,7 +572,7 @@ func (d *DriveService) UpdateFile(ctx context.Context, r *UpdateFileInfo) (*Driv doc := responseInfo.Results[0].Document item := DriveItem{ - Drivewsid: "FILE::com.apple.CloudDocs::" + doc.DocumentID, + Drivewsid: ConstructDriveID(doc.DocumentID, zone, "FILE"), Docwsid: doc.DocumentID, Itemid: doc.ItemID, Etag: doc.Etag, @@ -892,6 +892,18 @@ func DeconstructDriveID(id string) (docType, zone, docid string) { return split[0], split[1], split[2] } +// ZoneFromDriveID returns the zone a drive ID belongs to, or the default zone +// for an ID that carries none. Items in an app container -- Obsidian, Pages, +// Shortcuts -- live in that app's zone rather than com.apple.CloudDocs, and a +// write addressed to the wrong zone cannot resolve its parent. +func ZoneFromDriveID(id string) string { + _, zone, _ := DeconstructDriveID(id) + if zone == "" { + return defaultZone + } + return zone +} + // ConstructDriveID constructs a drive ID from the given components. func ConstructDriveID(id string, zone string, t string) string { return strings.Join([]string{t, zone, id}, "::") diff --git a/backend/iclouddrive/api/drive_test.go b/backend/iclouddrive/api/drive_test.go new file mode 100644 index 000000000..112f3f765 --- /dev/null +++ b/backend/iclouddrive/api/drive_test.go @@ -0,0 +1,32 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestZoneFromDriveID(t *testing.T) { + for _, test := range []struct { + name string + id string + want string + }{ + {"app container", "FOLDER::iCloud.md.obsidian::documents#o2v", "iCloud.md.obsidian"}, + {"another app container", "FOLDER::com.apple.Pages::documents#7qt", "com.apple.Pages"}, + {"ordinary folder", "FOLDER::com.apple.CloudDocs::B847FE2D", "com.apple.CloudDocs"}, + {"file in a container", "FILE::iCloud.md.obsidian::abc123", "iCloud.md.obsidian"}, + {"no zone", "root", defaultZone}, + {"empty", "", defaultZone}, + } { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, ZoneFromDriveID(test.id)) + }) + } +} + +func TestZoneFromDriveIDRoundTrip(t *testing.T) { + id := ConstructDriveID("abc123", "iCloud.md.obsidian", "FILE") + assert.Equal(t, "FILE::iCloud.md.obsidian::abc123", id) + assert.Equal(t, "iCloud.md.obsidian", ZoneFromDriveID(id)) +} diff --git a/backend/iclouddrive/iclouddrive.go b/backend/iclouddrive/iclouddrive.go index 90be7a712..3c78e3bce 100644 --- a/backend/iclouddrive/iclouddrive.go +++ b/backend/iclouddrive/iclouddrive.go @@ -343,7 +343,7 @@ func (f *Fs) Copy(ctx context.Context, src fs.Object, remote string) (fs.Object, var item *api.DriveItem if err = f.pacer.Call(func() (bool, error) { - item, resp, err = f.service.UpdateFile(ctx, &r) + item, resp, err = f.service.UpdateFile(ctx, &r, api.ZoneFromDriveID(pathID)) return retryResultUnknown(ctx, resp, err) }); err != nil { return nil, err @@ -949,7 +949,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op // Create document var uploadInfo *api.UploadResponse if err = o.fs.pacer.Call(func() (bool, error) { - uploadInfo, resp, err = o.fs.service.CreateUpload(ctx, size, name) + uploadInfo, resp, err = o.fs.service.CreateUpload(ctx, size, name, api.ZoneFromDriveID(dirID)) return ignoreResultUnknown(ctx, resp, err) }); err != nil { return err @@ -988,7 +988,7 @@ func (o *Object) Update(ctx context.Context, in io.Reader, src fs.ObjectInfo, op // Update metadata var item *api.DriveItem if err = o.fs.pacer.Call(func() (bool, error) { - item, resp, err = o.fs.service.UpdateFile(ctx, &r) + item, resp, err = o.fs.service.UpdateFile(ctx, &r, api.ZoneFromDriveID(dirID)) return ignoreResultUnknown(ctx, resp, err) }); err != nil { return err