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
4 changes: 2 additions & 2 deletions cmd/cloudstic/cmd_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ func (r *runner) runAuthLogin() int {
if googleCreds == "" {
googleCreds = os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
}
src, err := initSource(ctx, "gdrive:/", false, "", googleCreds, auth.GoogleTokenFile, "", "", g, nil)
src, err := initSource(ctx, "gdrive:/", false, "", googleCreds, auth.GoogleTokenFile, "", "", false, false, false, "", g, nil)
if err != nil {
return r.fail("Failed to initialize Google auth source: %v", err)
}
Expand All @@ -262,7 +262,7 @@ func (r *runner) runAuthLogin() int {
if onedriveClientID == "" {
onedriveClientID = os.Getenv("ONEDRIVE_CLIENT_ID")
}
src, err := initSource(ctx, "onedrive:/", false, "", "", "", onedriveClientID, auth.OneDriveTokenFile, g, nil)
src, err := initSource(ctx, "onedrive:/", false, "", "", "", onedriveClientID, auth.OneDriveTokenFile, false, false, false, "", g, nil)
if err != nil {
return r.fail("Failed to initialize OneDrive auth source: %v", err)
}
Expand Down
59 changes: 57 additions & 2 deletions cmd/cloudstic/cmd_backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ type backupArgs struct {
googleTokenFile string
onedriveClientID string
onedriveTokenFile string
skipMode bool
skipFlags bool
skipXattrs bool
xattrNamespaces string
tags stringArrayFlags
excludes stringArrayFlags
flagsSet map[string]bool
Expand All @@ -53,6 +57,10 @@ func parseBackupArgs() *backupArgs {
googleTokenFile := fs.String("google-token-file", envDefault("GOOGLE_TOKEN_FILE", ""), "Path to Google OAuth token file")
onedriveClientID := fs.String("onedrive-client-id", envDefault("ONEDRIVE_CLIENT_ID", ""), "OneDrive OAuth client ID")
onedriveTokenFile := fs.String("onedrive-token-file", envDefault("ONEDRIVE_TOKEN_FILE", ""), "Path to OneDrive OAuth token file")
skipMode := fs.Bool("skip-mode", false, "Skip POSIX mode, uid, gid, btime, and flags collection")
skipFlags := fs.Bool("skip-flags", false, "Skip file flags collection")
skipXattrs := fs.Bool("skip-xattrs", false, "Skip extended attribute collection")
xattrNamespaces := fs.String("xattr-namespaces", "", "Restrict xattr collection to these prefixes (comma-separated, e.g. \"user.,com.apple.\")")
fs.Var(&a.tags, "tag", "Tag to apply to the snapshot (can be specified multiple times)")
fs.Var(&a.excludes, "exclude", "Exclude pattern (gitignore syntax, repeatable)")
mustParse(fs)
Expand All @@ -69,6 +77,10 @@ func parseBackupArgs() *backupArgs {
a.googleTokenFile = *googleTokenFile
a.onedriveClientID = *onedriveClientID
a.onedriveTokenFile = *onedriveTokenFile
a.skipMode = *skipMode
a.skipFlags = *skipFlags
a.skipXattrs = *skipXattrs
a.xattrNamespaces = *xattrNamespaces
a.flagsSet = map[string]bool{}
fs.Visit(func(f *flag.Flag) {
a.flagsSet[f.Name] = true
Expand Down Expand Up @@ -116,7 +128,22 @@ func (r *runner) runSingleBackup(a *backupArgs) int {

ctx := context.Background()

src, err := initSource(ctx, a.sourceURI, a.skipNativeFiles, a.volumeUUID, a.googleCreds, a.googleTokenFile, a.onedriveClientID, a.onedriveTokenFile, a.g, excludePatterns)
src, err := initSource(
ctx,
a.sourceURI,
a.skipNativeFiles,
a.volumeUUID,
a.googleCreds,
a.googleTokenFile,
a.onedriveClientID,
a.onedriveTokenFile,
a.skipMode,
a.skipFlags,
a.skipXattrs,
a.xattrNamespaces,
a.g,
excludePatterns,
)
if err != nil {
return r.fail("Failed to init source: %v", err)
}
Expand Down Expand Up @@ -576,7 +603,7 @@ func (r *runner) printBackupSummary(res *engine.RunResult) {
}
}

func initSource(ctx context.Context, sourceURI string, skipNativeFiles bool, volumeUUID, googleCreds, googleTokenFile, onedriveClientID, onedriveTokenFile string, g *globalFlags, excludePatterns []string) (source.Source, error) {
func initSource(ctx context.Context, sourceURI string, skipNativeFiles bool, volumeUUID, googleCreds, googleTokenFile, onedriveClientID, onedriveTokenFile string, skipMode, skipFlags, skipXattrs bool, xattrNamespaces string, g *globalFlags, excludePatterns []string) (source.Source, error) {
uri, err := parseSourceURI(sourceURI)
if err != nil {
return nil, err
Expand All @@ -588,6 +615,21 @@ func initSource(ctx context.Context, sourceURI string, skipNativeFiles bool, vol
if volumeUUID != "" {
opts = append(opts, source.WithVolumeUUID(volumeUUID))
}
if skipMode {
opts = append(opts, source.WithSkipMode())
}
if skipFlags {
opts = append(opts, source.WithSkipFlags())
}
if skipXattrs {
opts = append(opts, source.WithSkipXattrs())
}
if xattrNamespaces != "" {
prefixes := parseXattrNamespacePrefixes(xattrNamespaces)
if len(prefixes) > 0 {
opts = append(opts, source.WithXattrNamespaces(prefixes))
}
}
return source.NewLocalSource(uri.path, opts...), nil
case "sftp":
sftpOpts := g.buildSFTPSourceOpts(uri)
Expand Down Expand Up @@ -654,6 +696,19 @@ func initSource(ctx context.Context, sourceURI string, skipNativeFiles bool, vol
}
}

func parseXattrNamespacePrefixes(raw string) []string {
parts := strings.Split(raw, ",")
prefixes := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
prefixes = append(prefixes, p)
}
return prefixes
}

// resolveTokenPath returns the token file path to use. If explicit is non-empty
// it is used as-is; otherwise the filename is placed in the cloudstic config dir.
func resolveTokenPath(explicit, defaultFilename string) (string, error) {
Expand Down
108 changes: 108 additions & 0 deletions cmd/cloudstic/cmd_backup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package main

import (
"context"
"strings"
"testing"
)

func TestInitSource_Local_ExtendedOptions(t *testing.T) {
tmpDir := t.TempDir()
a := &backupArgs{
skipMode: true,
skipFlags: true,
skipXattrs: true,
xattrNamespaces: "user.,com.apple.",
}
g := &globalFlags{}

src, err := initSource(context.Background(), "local:"+tmpDir, false, "", "", "", "", "", a.skipMode, a.skipFlags, a.skipXattrs, a.xattrNamespaces, g, nil)
if err != nil {
t.Fatalf("initSource failed: %v", err)
}
if src == nil {
t.Fatal("expected non-nil source")
}

// Verify info reflects local source.
info := src.Info()
if info.Type != "local" {
t.Errorf("expected source type 'local', got %q", info.Type)
}
}

func TestInitSource_Local_NoExtendedOptions(t *testing.T) {
tmpDir := t.TempDir()
a := &backupArgs{}
g := &globalFlags{}

src, err := initSource(context.Background(), "local:"+tmpDir, false, "", "", "", "", "", a.skipMode, a.skipFlags, a.skipXattrs, a.xattrNamespaces, g, nil)
if err != nil {
t.Fatalf("initSource failed: %v", err)
}
if src == nil {
t.Fatal("expected non-nil source")
}
}

func TestInitSource_Local_VolumeUUID(t *testing.T) {
tmpDir := t.TempDir()
a := &backupArgs{}
g := &globalFlags{}

src, err := initSource(context.Background(), "local:"+tmpDir, false, "test-uuid-123", "", "", "", "", a.skipMode, a.skipFlags, a.skipXattrs, a.xattrNamespaces, g, nil)
if err != nil {
t.Fatalf("initSource failed: %v", err)
}
info := src.Info()
if info.VolumeUUID != "test-uuid-123" {
t.Errorf("expected VolumeUUID 'test-uuid-123', got %q", info.VolumeUUID)
}
}

func TestInitSource_Local_XattrNamespacesParsing(t *testing.T) {
tmpDir := t.TempDir()
a := &backupArgs{
xattrNamespaces: "user.,com.apple.",
}
g := &globalFlags{}

src, err := initSource(context.Background(), "local:"+tmpDir, false, "", "", "", "", "", a.skipMode, a.skipFlags, a.skipXattrs, a.xattrNamespaces, g, nil)
if err != nil {
t.Fatalf("initSource failed: %v", err)
}
if src == nil {
t.Fatal("expected non-nil source")
}
}

func TestInitSource_UnsupportedType(t *testing.T) {
a := &backupArgs{}
g := &globalFlags{}

_, err := initSource(context.Background(), "invalid-source:/", false, "", "", "", "", "", a.skipMode, a.skipFlags, a.skipXattrs, a.xattrNamespaces, g, nil)
if err == nil {
t.Fatal("expected error for unsupported source type")
}
if !strings.Contains(err.Error(), "unknown source scheme") {
t.Errorf("expected 'unknown source scheme' error, got: %v", err)
}
}

func TestParseXattrNamespacePrefixes(t *testing.T) {
got := parseXattrNamespacePrefixes("user., com.apple., ,security.,")
want := []string{"user.", "com.apple.", "security."}
if len(got) != len(want) {
t.Fatalf("len=%d want=%d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d]=%q want=%q", i, got[i], want[i])
}
}
}

func TestPrintUsage_Smoke(t *testing.T) {
// Verify printUsage doesn't panic.
printUsage()
}
22 changes: 15 additions & 7 deletions cmd/cloudstic/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ _cloudstic() {
case "${words[i]}" in
-*)
# skip flags and their values
case "${words[i]}" in
-store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-store-sftp-password|-store-sftp-key|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-auth-ref|-google-credentials|-google-token-file|-onedrive-client-id|-onedrive-token-file|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account|-json)
case "${words[i]}" in
-store|-profile|-profiles-file|-s3-endpoint|-s3-region|-s3-profile|-s3-access-key|-s3-secret-key|-source-sftp-password|-source-sftp-key|-store-sftp-password|-store-sftp-key|-encryption-key|-password|-recovery-key|-kms-key-arn|-kms-region|-kms-endpoint|-source|-all-profiles|-auth-ref|-google-credentials|-google-token-file|-onedrive-client-id|-onedrive-token-file|-tag|-output|-keep-last|-keep-hourly|-keep-daily|-keep-weekly|-keep-monthly|-keep-yearly|-group-by|-account|-json|-xattr-namespaces)
((i++)) ;;
esac
;;
Expand All @@ -73,10 +73,10 @@ _cloudstic() {
# Complete flags per subcommand
local cmd_flags=""
case "$cmd" in
init)
cmd_flags="-add-recovery-key -no-encryption -adopt-slots" ;;
backup)
cmd_flags="-source -profile -all-profiles -auth-ref -profiles-file -skip-native-files -google-credentials -google-token-file -onedrive-client-id -onedrive-token-file -tag -dry-run" ;;
init)
cmd_flags="-add-recovery-key -no-encryption -adopt-slots" ;;
backup)
cmd_flags="-source -profile -all-profiles -auth-ref -profiles-file -skip-native-files -google-credentials -google-token-file -onedrive-client-id -onedrive-token-file -tag -dry-run -skip-mode -skip-flags -skip-xattrs -xattr-namespaces" ;;
restore)
cmd_flags="-output -format -path -dry-run" ;;
prune)
Expand Down Expand Up @@ -321,7 +321,11 @@ _cloudstic() {
'-onedrive-client-id[OneDrive OAuth client ID]:id:' \
'-onedrive-token-file[OneDrive OAuth token file]:path:_files' \
'*-tag[Tag for the snapshot]:tag:' \
'-dry-run[Scan without writing]'
'-dry-run[Scan without writing]' \
'-skip-mode[Skip POSIX mode/uid/gid/btime/flags]' \
'-skip-flags[Skip file flags collection]' \
'-skip-xattrs[Skip extended attribute collection]' \
'-xattr-namespaces[Restrict xattr collection to prefixes]:prefixes:'
;;
profile)
local -a profile_commands
Expand Down Expand Up @@ -652,6 +656,10 @@ complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-client
complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l onedrive-token-file -r -F -d 'OneDrive OAuth token file'
complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l tag -x -d 'Tag for the snapshot'
complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l dry-run -d 'Scan without writing'
complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-mode -d 'Skip POSIX mode/uid/gid/btime/flags'
complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-flags -d 'Skip file flags collection'
complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l skip-xattrs -d 'Skip extended attribute collection'
complete -c cloudstic -n '__fish_seen_subcommand_from backup' -l xattr-namespaces -x -d 'Restrict xattr collection to prefixes'

# profile subcommands
complete -c cloudstic -n '__fish_seen_subcommand_from profile; and not __fish_seen_subcommand_from list show new' -a list -d 'List stores, auth entries, and backup profiles'
Expand Down
4 changes: 4 additions & 0 deletions cmd/cloudstic/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ func printUsage() {
{"-exclude <pattern>", "Exclude pattern, gitignore syntax (repeatable)"},
{"-exclude-file <path>", "Load exclude patterns from file (one per line, gitignore syntax)"},
{"-dry-run", "Scan source and report changes without writing to the store"},
{"-skip-mode", "Skip POSIX mode, uid, gid, btime, and flags collection"},
{"-skip-flags", "Skip file flags collection"},
{"-skip-xattrs", "Skip extended attribute collection"},
{"-xattr-namespaces <prefixes>", "Restrict xattr collection to prefixes (comma-separated)"},
})
t.Blank()
t.Note(
Expand Down
19 changes: 16 additions & 3 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,13 @@ All objects are stored under a flat key namespace of the form `<type>/<hash>`.
"size": 21733,
"mtime": 1710000000,
"owner": "user@example.com",
"extra": { "mimeType": "application/pdf" }
"extra": { "mimeType": "application/pdf" },
"mode": 33261,
"uid": 501,
"gid": 20,
"btime": 1710000000,
"flags": 0,
"xattrs": { "user.tag": "cHJvamVjdA==" }
}
```

Expand All @@ -173,6 +179,12 @@ All objects are stored under a flat key namespace of the form `<type>/<hash>`.
| `content_ref` | Opaque content reference used as `content/<content_ref>` key; HMAC of `content_hash` for encrypted repos, plain `content_hash` for unencrypted repos |
| `paths` | Reserved for future use (multi-path support) |
| `extra` | Source-specific metadata (e.g. MIME type) |
| `mode` | POSIX file mode bits (e.g. `0755` = `493`). Omitted if zero. |
| `uid` | Numeric owner user ID. Omitted if zero. |
| `gid` | Numeric owner group ID. Omitted if zero. |
| `btime` | File creation (birth) time as Unix epoch seconds. Omitted if zero. |
| `flags` | OS-specific file flags (macOS `UF_*`/`SF_*`, Linux `FS_*_FL`). Omitted if zero. |
| `xattrs` | Extended attributes as `name → base64(value)` map. Omitted if empty.|

* `fileId` is **the HAMT key**.
* Folders have an empty `content_hash`, `content_ref`, and `size` of 0.
Expand Down Expand Up @@ -225,7 +237,8 @@ Object key: `node/<sha256-of-serialized-json>`
"source": {
"type": "gdrive",
"account": "user@gmail.com",
"path": "my-drive://"
"path": "my-drive://",
"fs_type": "google-drive"
},
"meta": {
"generator": "cloudstic-cli"
Expand All @@ -239,7 +252,7 @@ Object key: `node/<sha256-of-serialized-json>`
| Field | Description |
|----------------|----------------------------------------------------------------------|
| `seq` | Monotonically increasing sequence number |
| `source` | Origin of the backup (type, account, path) — used for retention grouping |
| `source` | Origin of the backup (type, account, path, fs_type) — used for retention grouping |
| `meta` | Free-form key-value metadata (generator, etc.) |
| `tags` | User-defined labels for retention policies |
| `change_token` | Opaque token for incremental sources (omitted when not applicable) |
Expand Down
30 changes: 28 additions & 2 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,10 @@ cloudstic backup -source local:~/Documents -dry-run
| `-exclude` | | Exclude pattern using gitignore syntax (repeatable) |
| `-exclude-file` | | Path to file containing exclude patterns, one per line |
| `-volume-uuid` | | Override volume UUID for local source (enables cross-machine incremental backup for portable drives) |
| `-skip-mode` | | Skip POSIX metadata collection (mode, uid, gid, btime, flags) |
| `-skip-flags` | | Skip file flags collection |
| `-skip-xattrs` | | Skip extended attribute collection |
| `-xattr-namespaces` | | Comma-separated xattr namespace prefixes to collect (e.g. `user.,com.apple.`) |
| `-dry-run` | `false` | Scan source and report changes without writing to the store |

`-profile` and `-all-profiles` are mutually exclusive.
Expand Down Expand Up @@ -1156,8 +1160,28 @@ cloudstic backup -source local:~/project -exclude-file .backupignore
| `-exclude` | | Exclude pattern, gitignore syntax (repeatable) |
| `-exclude-file` | | File containing exclude patterns (one per line) |
| `-volume-uuid` | | Override volume UUID (see [Portable drives](#portable-drives)) |
| `-skip-mode` | | Skip POSIX metadata collection (mode, uid, gid, btime, flags) |
| `-skip-flags` | | Skip file flags collection |
| `-skip-xattrs` | | Skip extended attribute collection |
| `-xattr-namespaces` | | Comma-separated xattr namespace prefixes to collect (e.g. `user.,com.apple.`) |

Cloudstic walks the directory recursively. Symbolic links are not followed. File permissions are not preserved — only name, size, modification time, and content are captured.
Cloudstic walks the directory recursively. Symbolic links are not followed.

**Extended file attributes:** By default, Cloudstic captures POSIX permissions (mode bits), numeric ownership (uid/gid), file creation time (btime, where supported), per-file flags, and extended attributes (xattrs). These are stored in each snapshot and will be used by future restore modes to faithfully recreate file metadata. To control what is captured:

```bash
# Skip all POSIX metadata (mode, uid, gid, btime, flags)
cloudstic backup -source local:/data -skip-mode

# Skip only file flags
cloudstic backup -source local:/data -skip-flags

# Skip extended attributes
cloudstic backup -source local:/data -skip-xattrs

# Collect only user.* xattrs (skip security.*, system.*, etc.)
cloudstic backup -source local:/data -xattr-namespaces "user."
```

See [Exclude patterns](#exclude-patterns) for the full pattern syntax reference.

Expand Down Expand Up @@ -1227,7 +1251,9 @@ cloudstic backup -source sftp://backup@myserver.com/home/user/files \

If neither `-source-sftp-password` nor `-source-sftp-key` is provided, Cloudstic will fall back to your `SSH_AUTH_SOCK` agent.

Cloudstic walks the remote directory recursively. File permissions are not preserved — only name, size, modification time, and content are captured.
SFTP backups capture file permissions (mode bits) and numeric ownership (uid/gid) via the SFTPv3 protocol. Birth time, file flags, and extended attributes are not available over SFTP.

Cloudstic walks the remote directory recursively. Mode bits and uid/gid are captured in snapshot metadata. Restore application of these fields depends on restore format support.

The `-exclude` and `-exclude-file` flags work with SFTP sources. See [Exclude patterns](#exclude-patterns) for the full pattern syntax.

Expand Down
Loading
Loading