Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions cmd/cloudstic/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,9 @@ func (r *runner) renderSnapshotTable(entries []engine.SnapshotEntry, reasons map
if e.Snap.Source != nil {
source = e.Snap.Source.Type
if e.Snap.Source.VolumeLabel != "" {
account = e.Snap.Source.VolumeLabel
} else {
account = e.Snap.Source.Account
source += " (" + e.Snap.Source.VolumeLabel + ")"
}
account = e.Snap.Source.Account
path = e.Snap.Source.Path
} else if e.Snap.Meta != nil {
source = e.Snap.Meta["source"]
Expand Down
44 changes: 30 additions & 14 deletions pkg/source/gdrive.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ type GDriveSource struct {
driveID string // shared drive ID; empty means "My Drive"
rootFolderID string // if empty, defaults to "root" (entire drive)
account string // Google account email; populated automatically
driveName string // shared drive name; populated during construction
exclude *ExcludeMatcher
skipNativeFiles bool
mimeTypes map[string]string // fileID → mimeType; populated during Walk/WalkChanges
Expand Down Expand Up @@ -160,44 +161,59 @@ func NewGDriveSource(ctx context.Context, opts ...GDriveOption) (*GDriveSource,
}
}

return &GDriveSource{
src := &GDriveSource{
service: srv,
driveID: cfg.driveID,
rootFolderID: cfg.rootFolderID,
account: cfg.accountEmail,
exclude: NewExcludeMatcher(cfg.excludePatterns),
skipNativeFiles: cfg.skipNativeFiles,
}, nil
}

// Resolve the shared drive name for VolumeLabel.
if cfg.driveID != "" {
if d, err := srv.Drives.Get(cfg.driveID).Fields("name").Do(); err == nil {
src.driveName = d.Name
}
}

return src, nil
}

func (s *GDriveSource) Info() core.SourceInfo {
account := s.account
if account == "" {
if about, err := s.service.About.Get().Fields("user(emailAddress)").Do(); err == nil && about.User != nil {
account = about.User.EmailAddress
s.account = account
}
}
return core.SourceInfo{

path := "/"
if s.rootFolderID != "" {
path = s.rootFolderID
}

info := core.SourceInfo{
Type: "gdrive",
Account: account,
Path: drivePath(s.driveID, s.rootFolderID),
Path: path,
}

if s.isSharedDrive() {
info.VolumeUUID = s.driveID
info.VolumeLabel = s.driveName
} else {
info.VolumeLabel = "My Drive"
}

return info
}

func (s *GDriveSource) isSharedDrive() bool {
return s.driveID != ""
}

// drivePath builds a URI-like path that uniquely identifies the drive and
// optional root folder: "my-drive://" or "<driveID>://<rootFolderID>".
func drivePath(driveID, rootFolderID string) string {
drive := "my-drive"
if driveID != "" {
drive = driveID
}
return drive + "://" + rootFolderID
}

// ---------------------------------------------------------------------------
// OAuth helpers
// ---------------------------------------------------------------------------
Expand Down
89 changes: 89 additions & 0 deletions pkg/source/gdrive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,3 +486,92 @@ func TestWithSkipNativeFiles(t *testing.T) {
t.Error("WithSkipNativeFiles should set skipNativeFiles to true")
}
}

func TestGDriveInfo_MyDrive_Root(t *testing.T) {
s := &GDriveSource{account: "user@gmail.com"}
info := s.Info()

if info.Type != "gdrive" {
t.Errorf("Type = %q, want gdrive", info.Type)
}
if info.Account != "user@gmail.com" {
t.Errorf("Account = %q, want user@gmail.com", info.Account)
}
if info.Path != "/" {
t.Errorf("Path = %q, want /", info.Path)
}
if info.VolumeUUID != "" {
t.Errorf("VolumeUUID = %q, want empty for My Drive", info.VolumeUUID)
}
if info.VolumeLabel != "My Drive" {
t.Errorf("VolumeLabel = %q, want My Drive", info.VolumeLabel)
}
}

func TestGDriveInfo_MyDrive_Subfolder(t *testing.T) {
s := &GDriveSource{account: "user@gmail.com", rootFolderID: "folder123"}
info := s.Info()

if info.Path != "folder123" {
t.Errorf("Path = %q, want folder123", info.Path)
}
if info.VolumeUUID != "" {
t.Errorf("VolumeUUID = %q, want empty for My Drive", info.VolumeUUID)
}
if info.VolumeLabel != "My Drive" {
t.Errorf("VolumeLabel = %q, want My Drive", info.VolumeLabel)
}
}

func TestGDriveInfo_SharedDrive_Root(t *testing.T) {
s := &GDriveSource{
account: "user@gmail.com",
driveID: "shared-drive-abc",
driveName: "Team Photos",
}
info := s.Info()

if info.Path != "/" {
t.Errorf("Path = %q, want /", info.Path)
}
if info.VolumeUUID != "shared-drive-abc" {
t.Errorf("VolumeUUID = %q, want shared-drive-abc", info.VolumeUUID)
}
if info.VolumeLabel != "Team Photos" {
t.Errorf("VolumeLabel = %q, want Team Photos", info.VolumeLabel)
}
}

func TestGDriveInfo_SharedDrive_Subfolder(t *testing.T) {
s := &GDriveSource{
account: "user@gmail.com",
driveID: "shared-drive-abc",
driveName: "Team Photos",
rootFolderID: "folder456",
}
info := s.Info()

if info.Path != "folder456" {
t.Errorf("Path = %q, want folder456", info.Path)
}
if info.VolumeUUID != "shared-drive-abc" {
t.Errorf("VolumeUUID = %q, want shared-drive-abc", info.VolumeUUID)
}
if info.VolumeLabel != "Team Photos" {
t.Errorf("VolumeLabel = %q, want Team Photos", info.VolumeLabel)
}
}

func TestGDriveChangesInfo_Type(t *testing.T) {
s := &GDriveChangeSource{
GDriveSource: GDriveSource{account: "user@gmail.com"},
}
info := s.Info()

if info.Type != "gdrive-changes" {
t.Errorf("Type = %q, want gdrive-changes", info.Type)
}
if info.VolumeLabel != "My Drive" {
t.Errorf("VolumeLabel = %q, want My Drive", info.VolumeLabel)
}
}
7 changes: 4 additions & 3 deletions pkg/source/onedrive.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,10 @@ func (s *OneDriveSource) Info() core.SourceInfo {
s.account = s.fetchAccount()
}
return core.SourceInfo{
Type: "onedrive",
Account: s.account,
Path: "onedrive://",
Type: "onedrive",
Account: s.account,
Path: "/",
VolumeLabel: "My Drive",
}
}

Expand Down
41 changes: 41 additions & 0 deletions pkg/source/onedrive_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package source

import "testing"

func TestOneDriveInfo(t *testing.T) {
s := &OneDriveSource{account: "user@outlook.com"}
info := s.Info()

if info.Type != "onedrive" {
t.Errorf("Type = %q, want onedrive", info.Type)
}
if info.Account != "user@outlook.com" {
t.Errorf("Account = %q, want user@outlook.com", info.Account)
}
if info.Path != "/" {
t.Errorf("Path = %q, want /", info.Path)
}
if info.VolumeUUID != "" {
t.Errorf("VolumeUUID = %q, want empty", info.VolumeUUID)
}
if info.VolumeLabel != "My Drive" {
t.Errorf("VolumeLabel = %q, want My Drive", info.VolumeLabel)
}
}

func TestOneDriveChangesInfo_Type(t *testing.T) {
s := &OneDriveChangeSource{
OneDriveSource: OneDriveSource{account: "user@outlook.com"},
}
info := s.Info()

if info.Type != "onedrive-changes" {
t.Errorf("Type = %q, want onedrive-changes", info.Type)
}
if info.VolumeLabel != "My Drive" {
t.Errorf("VolumeLabel = %q, want My Drive", info.VolumeLabel)
}
if info.Path != "/" {
t.Errorf("Path = %q, want /", info.Path)
}
}
26 changes: 14 additions & 12 deletions rfcs/0005-portable-drive-identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,24 @@ A drive has a stable identity when its **volume UUID** matches. The volume UUID
* It changes: reformatting (`diskutil eraseDisk`, `mkfs`), which correctly starts a new snapshot lineage.
* Edge cases: cloning a disk image may duplicate the UUID (VM snapshots, `dd` copies). This is rare and documented but not handled specially.

### 1.4 Remote sources are unaffected
### 1.4 Cloud source identity

Remote and network sources already produce machine-agnostic identity without any changes:
Cloud sources now use the same `VolumeUUID`/`VolumeLabel` fields as local sources where applicable:

| Source | `Account` | `Path` | Cross-machine stable? |
|---|---|---|---|
| `gdrive` / `gdrive-changes` | Gmail address (`user@gmail.com`) | Drive/folder ID (`drivePath(driveID, rootFolderID)`) | ✓ |
| `onedrive` / `onedrive-changes` | User principal name (`user@company.com`) | `onedrive://` | ✓ |
| `sftp` | `user@host` | Root path on server | ✓ (tied to server, not client) |
| Source | `Account` | `Path` | `VolumeUUID` | `VolumeLabel` |
|---|---|---|---|---|
| `gdrive` (My Drive) | Gmail address | `/` or `<folderID>` | *(empty)* | `My Drive` |
| `gdrive` (shared drive) | Gmail address | `/` or `<folderID>` | `<driveID>` | Drive name |
| `onedrive` | User principal name | `/` | *(empty)* | `My Drive` |
| `sftp` | `user@host` | Root path on server | *(empty)* | *(empty)* |

For **shared drives**, `VolumeUUID` enables cross-account matching: different Google accounts accessing the same shared drive produce the same `VolumeUUID`, so `findPreviousSnapshot` Pass 1 (`Type + VolumeUUID + Path`) matches regardless of which account runs the backup.

`Account` for cloud sources is the authenticated account identifier, not the hostname of the machine running the backup. Plugging in from a different machine with the same OAuth token produces an identical `SourceInfo`, so `findPreviousSnapshot` finds the previous snapshot normally. **No changes are required for any remote source.**
For **My Drive** and **OneDrive**, the account email is the stable identity — there is no globally unique drive identifier beyond the account itself. These use `findPreviousSnapshot` Pass 2 (`Type + Account + Path`).

`VolumeUUID` in `SourceInfo` is a `local`-only concept. The UUID-first pass in `findPreviousSnapshot` is guarded by `VolumeUUID != ""`, so it is never attempted for `gdrive`, `onedrive`, or `sftp` entries — those fall straight through to the existing `account+path` match, which already works correctly.
`VolumeLabel` is set for display purposes: `My Drive` for personal drives, or the shared drive name (fetched via `Drives.Get` at construction time).

The one edge case worth noting for `sftp`: if the server's hostname or IP changes (DNS rename, server migration), `Account = user@oldhost` no longer matches. This is analogous to the local source's `Path` instability, but for SFTP the host is the stable identity marker and migration is an explicit, operator-controlled event. A future RFC could introduce a `-sftp-server-id` override similar to `-volume-uuid` proposed here.
SFTP sources remain unchanged — the server `user@host` is the stable identity marker.

### 1.5 The cross-machine workflow

Expand Down Expand Up @@ -214,9 +217,8 @@ func (bm *BackupManager) findPreviousSnapshot(info core.SourceInfo) *core.Snapsh

**Pass 2** is the existing logic. It activates when:

* `VolumeUUID` is empty (UUID detection failed, or Windows stub).
* `VolumeUUID` is empty (UUID detection failed, My Drive, OneDrive, or SFTP).
* The drive has no previous snapshot from any machine (pass 1 found nothing).
* The source is not a local drive (e.g. `gdrive`, `sftp`) — those never set `VolumeUUID`.

### 2.4 Retention policy grouping

Expand Down
Loading