diff --git a/README.md b/README.md index a7ca4b7..613535a 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ cloudstic restore # Preview what a backup would do (dry run) cloudstic backup -source local:~/Documents -dry-run + +# Discover local source candidates and portable drives +cloudstic source discover -portable-only ``` ## Profiles diff --git a/client.go b/client.go index be41d8d..403b437 100644 --- a/client.go +++ b/client.go @@ -311,6 +311,7 @@ type ProfilesConfig = engine.ProfilesConfig type ProfileStore = engine.ProfileStore type ProfileAuth = engine.ProfileAuth type BackupProfile = engine.BackupProfile +type DiscoveredSource = engine.DiscoveredSource var ( WithVerbose = engine.WithVerbose @@ -337,6 +338,10 @@ func (c *Client) Backup(ctx context.Context, src source.Source, opts ...BackupOp return result, nil } +func (c *Client) DiscoverSources(ctx context.Context) ([]DiscoveredSource, error) { + return engine.DiscoverSources(ctx) +} + // LoadProfilesFile parses a backup profiles YAML file. func LoadProfilesFile(path string) (*ProfilesConfig, error) { return engine.LoadProfilesFile(path) diff --git a/client_test.go b/client_test.go index 255027b..5189e4e 100644 --- a/client_test.go +++ b/client_test.go @@ -216,6 +216,13 @@ func TestAddRecoveryKey_WrongCredentials(t *testing.T) { } } +func TestClientDiscoverSources(t *testing.T) { + c := &Client{} + if _, err := c.DiscoverSources(context.Background()); err != nil { + t.Fatalf("DiscoverSources: %v", err) + } +} + // --------------------------------------------------------------------------- // Cat // --------------------------------------------------------------------------- diff --git a/cmd/cloudstic/client_iface.go b/cmd/cloudstic/client_iface.go index a373c9c..65cb504 100644 --- a/cmd/cloudstic/client_iface.go +++ b/cmd/cloudstic/client_iface.go @@ -11,6 +11,7 @@ import ( // cloudsticClient is the interface commands use to interact with the repository. type cloudsticClient interface { Backup(ctx context.Context, src source.Source, opts ...cloudstic.BackupOption) (*cloudstic.BackupResult, error) + DiscoverSources(ctx context.Context) ([]cloudstic.DiscoveredSource, error) Restore(ctx context.Context, w io.Writer, snapshotRef string, opts ...cloudstic.RestoreOption) (*cloudstic.RestoreResult, error) RestoreToDir(ctx context.Context, outputDir, snapshotRef string, opts ...cloudstic.RestoreOption) (*cloudstic.RestoreResult, error) List(ctx context.Context, opts ...cloudstic.ListOption) (*cloudstic.ListResult, error) diff --git a/cmd/cloudstic/cmd_source.go b/cmd/cloudstic/cmd_source.go new file mode 100644 index 0000000..6450a08 --- /dev/null +++ b/cmd/cloudstic/cmd_source.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + cloudstic "github.com/cloudstic/cli" + "github.com/jedib0t/go-pretty/v6/table" +) + +func (r *runner) runSource(ctx context.Context) int { + if len(os.Args) < 3 { + _, _ = fmt.Fprintln(r.errOut, "Usage: cloudstic source [options]") + _, _ = fmt.Fprintln(r.errOut, "") + _, _ = fmt.Fprintln(r.errOut, "Available subcommands: discover") + return 1 + } + + switch os.Args[2] { + case "discover": + return r.runSourceDiscover(ctx) + default: + return r.fail("Unknown source subcommand: %s", os.Args[2]) + } +} + +func (r *runner) runSourceDiscover(ctx context.Context) int { + fs := flag.NewFlagSet("source discover", flag.ExitOnError) + portableOnly := fs.Bool("portable-only", false, "Only show portable/external source candidates") + jsonOutput := fs.Bool("json", false, "Write discovered sources as JSON") + _ = fs.Parse(reorderArgs(fs, os.Args[3:])) + + if r.client == nil { + r.client = &cloudstic.Client{} + } + + results, err := r.client.DiscoverSources(ctx) + if err != nil { + return r.fail("Failed to discover sources: %v", err) + } + + if *portableOnly { + filtered := results[:0] + for _, result := range results { + if result.Portable { + filtered = append(filtered, result) + } + } + results = filtered + } + + if *jsonOutput { + return r.writeJSON(results) + } + + if len(results) == 0 { + _, _ = fmt.Fprintln(r.out, "No sources discovered.") + return 0 + } + + t := table.NewWriter() + t.SetOutputMirror(r.out) + t.AppendHeader(table.Row{"Name", "Source URI", "Mount", "Identity", "FS", "Portable"}) + for _, result := range results { + t.AppendRow(table.Row{ + result.DisplayName, + result.SourceURI, + result.MountPoint, + result.Identity, + result.FsType, + boolLabel(result.Portable), + }) + } + t.Render() + return 0 +} diff --git a/cmd/cloudstic/cmd_source_test.go b/cmd/cloudstic/cmd_source_test.go new file mode 100644 index 0000000..bf46931 --- /dev/null +++ b/cmd/cloudstic/cmd_source_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" + + cloudstic "github.com/cloudstic/cli" +) + +func TestRunSourceDiscover(t *testing.T) { + client := &stubClient{ + discoverResult: []cloudstic.DiscoveredSource{ + {DisplayName: "System", SourceURI: "local:/", MountPoint: "/", Identity: "HOST-1", FsType: "apfs", Portable: false}, + {DisplayName: "Photos", SourceURI: "local:/Volumes/Photos", MountPoint: "/Volumes/Photos", Identity: "UUID-1", FsType: "exfat", Portable: true}, + }, + } + + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "source", "discover"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut, client: client} + if code := r.runSource(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + got := out.String() + if !strings.Contains(got, "Photos") || !strings.Contains(got, "local:/Volumes/Photos") { + t.Fatalf("unexpected output:\n%s", got) + } +} + +func TestRunSourceDiscover_PortableOnly(t *testing.T) { + client := &stubClient{ + discoverResult: []cloudstic.DiscoveredSource{ + {DisplayName: "System", SourceURI: "local:/", MountPoint: "/", Portable: false}, + {DisplayName: "Photos", SourceURI: "local:/Volumes/Photos", MountPoint: "/Volumes/Photos", Portable: true}, + }, + } + + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "source", "discover", "-portable-only"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut, client: client} + if code := r.runSource(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + got := out.String() + if strings.Contains(got, "System") { + t.Fatalf("expected portable-only output, got:\n%s", got) + } + if !strings.Contains(got, "Photos") { + t.Fatalf("missing portable source:\n%s", got) + } +} + +func TestRunSourceDiscover_JSON(t *testing.T) { + client := &stubClient{ + discoverResult: []cloudstic.DiscoveredSource{ + {DisplayName: "Photos", SourceURI: "local:/Volumes/Photos", MountPoint: "/Volumes/Photos", Portable: true}, + }, + } + + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "source", "discover", "-json"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut, client: client} + if code := r.runSource(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } + got := out.String() + if !strings.Contains(got, "\"source_uri\": \"local:/Volumes/Photos\"") { + t.Fatalf("unexpected json output:\n%s", got) + } +} + +func TestRunSourceDiscover_DefaultClient(t *testing.T) { + osArgs := os.Args + t.Cleanup(func() { os.Args = osArgs }) + os.Args = []string{"cloudstic", "source", "discover", "-json"} + + var out strings.Builder + var errOut strings.Builder + r := &runner{out: &out, errOut: &errOut} + if code := r.runSource(context.Background()); code != 0 { + t.Fatalf("code=%d err=%s", code, errOut.String()) + } +} diff --git a/cmd/cloudstic/completion.go b/cmd/cloudstic/completion.go index 4a9abfc..51ed688 100644 --- a/cmd/cloudstic/completion.go +++ b/cmd/cloudstic/completion.go @@ -41,7 +41,7 @@ _cloudstic() { local cur prev words cword _init_completion || return - local commands="init backup auth profile store restore list ls prune forget diff break-lock key cat completion version help" + local commands="init backup auth profile store source restore list ls prune forget diff break-lock key cat completion version help" local global_flags="-store -profile -profiles-file -s3-endpoint -s3-region -s3-profile -s3-access-key -s3-secret-key -source-sftp-password -source-sftp-key -source-sftp-known-hosts -source-sftp-insecure -store-sftp-password -store-sftp-key -store-sftp-known-hosts -store-sftp-insecure -encryption-key -password -recovery-key -kms-key-arn -kms-region -kms-endpoint -disable-packfile -prompt -no-prompt -verbose -quiet -json -debug" @@ -188,6 +188,26 @@ _cloudstic() { cmd_flags="" ;; esac ;; + source) + local source_sub="" + local j + for ((j=i+1; j < cword; j++)); do + case "${words[j]}" in + -*) ;; + *) source_sub="${words[j]}"; break ;; + esac + done + if [[ -z "$source_sub" ]]; then + COMPREPLY=($(compgen -W "discover" -- "$cur")) + return + fi + case "$source_sub" in + discover) + cmd_flags="-portable-only -json" ;; + *) + cmd_flags="" ;; + esac + ;; check) cmd_flags="-read-data" ;; ls|diff|break-lock|version|help) @@ -232,6 +252,7 @@ _cloudstic() { 'backup:Create a new backup snapshot from a source' 'auth:Manage reusable cloud auth entries' 'profile:Manage backup profiles' + 'source:Discover source candidates for onboarding' 'restore:Restore files from a backup snapshot' 'list:List all backup snapshots in the repository' 'ls:List files within a specific snapshot' @@ -505,6 +526,33 @@ _cloudstic() { ;; esac ;; + source) + local -a source_commands + source_commands=( + 'discover:Discover local source candidates' + ) + local source_sub + local -i soi=$((i+1)) + while (( soi < CURRENT )); do + case "${words[soi]}" in + -*) ;; + *) source_sub="${words[soi]}"; break ;; + esac + (( soi++ )) + done + if [[ -z "$source_sub" ]]; then + _describe -t source-commands 'source subcommand' source_commands + return + fi + case "$source_sub" in + discover) + _arguments '-portable-only[Only show portable or external source candidates]' '-json[Write discovered sources as JSON]' + ;; + *) + _arguments + ;; + esac + ;; restore) _arguments $global_flags \ '-output[Output path for zip or dir restore]:path:_files' \ @@ -607,6 +655,7 @@ complete -c cloudstic -n __fish_use_subcommand -a init -d 'Initialize a new repo complete -c cloudstic -n __fish_use_subcommand -a backup -d 'Create a new backup snapshot' complete -c cloudstic -n __fish_use_subcommand -a auth -d 'Manage reusable cloud auth entries' complete -c cloudstic -n __fish_use_subcommand -a profile -d 'Manage backup profiles' +complete -c cloudstic -n __fish_use_subcommand -a source -d 'Discover source candidates for onboarding' complete -c cloudstic -n __fish_use_subcommand -a restore -d 'Restore files from a snapshot' complete -c cloudstic -n __fish_use_subcommand -a list -d 'List all backup snapshots' complete -c cloudstic -n __fish_use_subcommand -a ls -d 'List files within a snapshot' @@ -723,6 +772,11 @@ complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_sub complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from init' -l profiles-file -r -F -d 'Path to profiles YAML file' complete -c cloudstic -n '__fish_seen_subcommand_from store; and __fish_seen_subcommand_from init' -l yes -d 'Initialize without confirmation prompt' +# source subcommands +complete -c cloudstic -n '__fish_seen_subcommand_from source; and not __fish_seen_subcommand_from discover' -a discover -d 'Discover local source candidates' +complete -c cloudstic -n '__fish_seen_subcommand_from source; and __fish_seen_subcommand_from discover' -l portable-only -d 'Only show portable or external source candidates' +complete -c cloudstic -n '__fish_seen_subcommand_from source; and __fish_seen_subcommand_from discover' -l json -d 'Write discovered sources as JSON' + # auth subcommands complete -c cloudstic -n '__fish_seen_subcommand_from auth; and not __fish_seen_subcommand_from list show new login' -a list -d 'List auth entries from profiles.yaml' complete -c cloudstic -n '__fish_seen_subcommand_from auth; and not __fish_seen_subcommand_from list show new login' -a show -d 'Show one auth entry' diff --git a/cmd/cloudstic/completion_test.go b/cmd/cloudstic/completion_test.go index 979620c..a2acdd9 100644 --- a/cmd/cloudstic/completion_test.go +++ b/cmd/cloudstic/completion_test.go @@ -20,7 +20,7 @@ func TestCompletionBash(t *testing.T) { "_cloudstic()", "complete -F _cloudstic cloudstic", // All commands are listed - "init", "backup", "auth", "profile", "restore", "list", "ls", "prune", "forget", + "init", "backup", "auth", "profile", "store", "source", "restore", "list", "ls", "prune", "forget", "diff", "break-lock", "key", "cat", "completion", // Key subcommands "list add-recovery passwd", @@ -66,6 +66,8 @@ func TestCompletionZsh(t *testing.T) { "login:Run OAuth login flow for one auth entry", "key:Manage encryption key slots", "completion:Generate shell completion scripts", + "source:Discover source candidates for onboarding", + "discover:Discover local source candidates", // Key subcommands "list:List all encryption key slots", "add-recovery:Generate a 24-word recovery key", @@ -109,6 +111,7 @@ func TestCompletionFish(t *testing.T) { "complete -c cloudstic -n __fish_use_subcommand -a backup", "complete -c cloudstic -n __fish_use_subcommand -a profile", "complete -c cloudstic -n __fish_use_subcommand -a auth", + "complete -c cloudstic -n __fish_use_subcommand -a source", "complete -c cloudstic -n __fish_use_subcommand -a key", "complete -c cloudstic -n __fish_use_subcommand -a completion", // Key subcommands diff --git a/cmd/cloudstic/main.go b/cmd/cloudstic/main.go index 68b5297..9cf2723 100644 --- a/cmd/cloudstic/main.go +++ b/cmd/cloudstic/main.go @@ -71,6 +71,8 @@ func runCmd(cmd string) int { return r.runAuth(ctx) case "store": return r.runStore(ctx) + case "source": + return r.runSource(ctx) case "completion": runCompletion() return 0 diff --git a/cmd/cloudstic/stub_client_test.go b/cmd/cloudstic/stub_client_test.go index 586274b..9c911b1 100644 --- a/cmd/cloudstic/stub_client_test.go +++ b/cmd/cloudstic/stub_client_test.go @@ -13,6 +13,8 @@ import ( type stubClient struct { backupResult *cloudstic.BackupResult backupErr error + discoverResult []cloudstic.DiscoveredSource + discoverErr error restoreResult *cloudstic.RestoreResult restoreErr error listResult *cloudstic.ListResult @@ -39,6 +41,10 @@ func (s *stubClient) Backup(_ context.Context, _ source.Source, _ ...cloudstic.B return s.backupResult, s.backupErr } +func (s *stubClient) DiscoverSources(_ context.Context) ([]cloudstic.DiscoveredSource, error) { + return s.discoverResult, s.discoverErr +} + func (s *stubClient) Restore(_ context.Context, _ io.Writer, _ string, _ ...cloudstic.RestoreOption) (*cloudstic.RestoreResult, error) { return s.restoreResult, s.restoreErr } diff --git a/cmd/cloudstic/usage.go b/cmd/cloudstic/usage.go index c05d5b2..7bd4fda 100644 --- a/cmd/cloudstic/usage.go +++ b/cmd/cloudstic/usage.go @@ -27,6 +27,7 @@ func printUsage() { {"store list", "List configured stores"}, {"store show", "Show one store and its configuration"}, {"store verify", "Verify one store's credentials and connectivity"}, + {"source discover", "Discover local source candidates for onboarding"}, {"profile new", "Create or update a backup profile in profiles.yaml"}, {"profile list", "List stores, auth entries, and backup profiles"}, {"profile show", "Show one profile and resolved store/auth references"}, @@ -217,6 +218,17 @@ func printUsage() { ) t.Blank() + t.Command("source discover", "") + t.Flags([][2]string{ + {"-portable-only", "Only show portable/external source candidates"}, + {"-json", "Write discovered sources as JSON"}, + }) + t.Note( + " Discover local source candidates with mount metadata and portable-drive hints.", + " Intended for workstation onboarding and source selection workflows.", + ) + t.Blank() + t.Command("profile list", "") t.Flags([][2]string{ {"-profiles-file ", ui.Env("Path to profiles YAML file", "CLOUDSTIC_PROFILES_FILE")}, diff --git a/docs/user-guide.md b/docs/user-guide.md index 3db8fdf..3adbbff 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -515,6 +515,31 @@ that provider matches the source type. --- +### source + +Inspect local source candidates for workstation onboarding. + +#### source discover + +List local source roots with mount metadata and portable-drive hints. + +```bash +cloudstic source discover + +# Only show portable/external candidates +cloudstic source discover -portable-only + +# JSON output for automation +cloudstic source discover -json +``` + +This command is intended to support workstation onboarding and source +selection flows. It emits candidate `local:` source URIs together with mount +metadata such as identity, filesystem type, and whether the source is +considered portable. + +--- + ### store Manage named store entries in `profiles.yaml`. Stores define storage backend, connection credentials, and encryption settings. diff --git a/internal/engine/source_discover.go b/internal/engine/source_discover.go new file mode 100644 index 0000000..e5dda0d --- /dev/null +++ b/internal/engine/source_discover.go @@ -0,0 +1,105 @@ +package engine + +import ( + "context" + "github.com/cloudstic/cli/internal/core" + "path/filepath" + "runtime" + "slices" + "strings" + + "github.com/cloudstic/cli/pkg/source" +) + +var ( + discoverLocalCandidatesFunc = discoverLocalCandidates + discoverLocalSourceInfoFunc = func(mountPoint string) core.SourceInfo { + return source.NewLocalSource(mountPoint).Info() + } +) + +// DiscoveredSource describes a local source candidate that can be used for +// onboarding and source selection flows. +type DiscoveredSource struct { + SourceURI string `json:"source_uri"` + DisplayName string `json:"display_name"` + MountPoint string `json:"mount_point"` + DriveName string `json:"drive_name,omitempty"` + Identity string `json:"identity,omitempty"` + PathID string `json:"path_id,omitempty"` + FsType string `json:"fs_type,omitempty"` + Portable bool `json:"portable"` +} + +type discoverCandidate struct { + mountPoint string + portable bool +} + +// DiscoverSources returns local source candidates suitable for workstation +// onboarding and source-selection UX. +func DiscoverSources(_ context.Context) ([]DiscoveredSource, error) { + candidates, err := discoverLocalCandidatesFunc() + if err != nil { + return nil, err + } + + byMount := make(map[string]discoverCandidate, len(candidates)) + for _, candidate := range candidates { + if candidate.mountPoint == "" { + continue + } + mountPoint := filepath.Clean(candidate.mountPoint) + if existing, ok := byMount[mountPoint]; ok { + existing.portable = existing.portable || candidate.portable + byMount[mountPoint] = existing + continue + } + candidate.mountPoint = mountPoint + byMount[mountPoint] = candidate + } + + mounts := make([]string, 0, len(byMount)) + for mountPoint := range byMount { + mounts = append(mounts, mountPoint) + } + slices.Sort(mounts) + + results := make([]DiscoveredSource, 0, len(mounts)) + for _, mountPoint := range mounts { + candidate := byMount[mountPoint] + info := discoverLocalSourceInfoFunc(mountPoint) + results = append(results, DiscoveredSource{ + SourceURI: "local:" + mountPoint, + DisplayName: discoverDisplayName(mountPoint, info.DriveName), + MountPoint: mountPoint, + DriveName: info.DriveName, + Identity: info.Identity, + PathID: info.PathID, + FsType: info.FsType, + Portable: candidate.portable, + }) + } + + return results, nil +} + +func discoverDisplayName(mountPoint, driveName string) string { + if driveName != "" { + return driveName + } + if runtime.GOOS == "windows" { + trimmed := strings.TrimRight(mountPoint, `\/`) + if trimmed != "" { + return trimmed + } + } + if mountPoint == "/" { + return "System" + } + base := filepath.Base(mountPoint) + if base == "." || base == string(filepath.Separator) || base == "" { + return mountPoint + } + return base +} diff --git a/internal/engine/source_discover_darwin.go b/internal/engine/source_discover_darwin.go new file mode 100644 index 0000000..c5ab0c7 --- /dev/null +++ b/internal/engine/source_discover_darwin.go @@ -0,0 +1,43 @@ +//go:build darwin + +package engine + +import ( + "os/exec" + "strings" +) + +func discoverLocalCandidates() ([]discoverCandidate, error) { + out, err := exec.Command("mount").Output() + if err != nil { + return nil, err + } + return parseDarwinMountOutput(string(out)), nil +} + +func parseDarwinMountOutput(out string) []discoverCandidate { + var candidates []discoverCandidate + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + onIdx := strings.Index(line, " on ") + if onIdx < 0 { + continue + } + rest := line[onIdx+4:] + openIdx := strings.Index(rest, " (") + if openIdx < 0 { + continue + } + mountPoint := strings.TrimSpace(rest[:openIdx]) + switch { + case mountPoint == "/": + candidates = append(candidates, discoverCandidate{mountPoint: mountPoint}) + case strings.HasPrefix(mountPoint, "/Volumes/"): + candidates = append(candidates, discoverCandidate{mountPoint: mountPoint, portable: true}) + } + } + return candidates +} diff --git a/internal/engine/source_discover_darwin_test.go b/internal/engine/source_discover_darwin_test.go new file mode 100644 index 0000000..ff01143 --- /dev/null +++ b/internal/engine/source_discover_darwin_test.go @@ -0,0 +1,27 @@ +//go:build darwin + +package engine + +import "testing" + +func TestParseDarwinMountOutput(t *testing.T) { + out := `/dev/disk3s1 on / (apfs, local, read-only, journaled) +/dev/disk4s1 on /Volumes/Photos (apfs, local, journaled) +/dev/disk5s1 on /Volumes/Archive Drive (exfat, local, nodev, nosuid) +/dev/disk3s6 on /System/Volumes/VM (apfs, local, noexec) +` + + got := parseDarwinMountOutput(out) + if len(got) != 3 { + t.Fatalf("len=%d want 3 (%v)", len(got), got) + } + if got[0].mountPoint != "/" || got[0].portable { + t.Fatalf("root candidate = %+v", got[0]) + } + if got[1].mountPoint != "/Volumes/Photos" || !got[1].portable { + t.Fatalf("photos candidate = %+v", got[1]) + } + if got[2].mountPoint != "/Volumes/Archive Drive" || !got[2].portable { + t.Fatalf("archive candidate = %+v", got[2]) + } +} diff --git a/internal/engine/source_discover_linux.go b/internal/engine/source_discover_linux.go new file mode 100644 index 0000000..d46bd55 --- /dev/null +++ b/internal/engine/source_discover_linux.go @@ -0,0 +1,71 @@ +//go:build linux + +package engine + +import ( + "os" + "strconv" + "strings" +) + +func discoverLocalCandidates() ([]discoverCandidate, error) { + data, err := os.ReadFile("/proc/mounts") + if err != nil { + return nil, err + } + return parseLinuxMounts(string(data)), nil +} + +func parseLinuxMounts(data string) []discoverCandidate { + var candidates []discoverCandidate + for _, line := range strings.Split(data, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + device := fields[0] + mountPoint := unescapeMountField(fields[1]) + if !strings.HasPrefix(device, "/dev/") { + continue + } + switch { + case mountPoint == "/": + candidates = append(candidates, discoverCandidate{mountPoint: mountPoint}) + case strings.HasPrefix(mountPoint, "/media/"), + strings.HasPrefix(mountPoint, "/run/media/"), + strings.HasPrefix(mountPoint, "/mnt/"): + candidates = append(candidates, discoverCandidate{mountPoint: mountPoint, portable: true}) + } + } + return candidates +} + +func unescapeMountField(s string) string { + if !strings.Contains(s, "\\") { + return s + } + + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + if s[i] != '\\' || i+3 >= len(s) { + out = append(out, s[i]) + continue + } + if !isOctalDigit(s[i+1]) || !isOctalDigit(s[i+2]) || !isOctalDigit(s[i+3]) { + out = append(out, s[i]) + continue + } + v, err := strconv.ParseUint(s[i+1:i+4], 8, 8) + if err != nil { + out = append(out, s[i]) + continue + } + out = append(out, byte(v)) + i += 3 + } + return string(out) +} + +func isOctalDigit(b byte) bool { + return b >= '0' && b <= '7' +} diff --git a/internal/engine/source_discover_linux_test.go b/internal/engine/source_discover_linux_test.go new file mode 100644 index 0000000..995728a --- /dev/null +++ b/internal/engine/source_discover_linux_test.go @@ -0,0 +1,43 @@ +//go:build linux + +package engine + +import "testing" + +func TestParseLinuxMounts(t *testing.T) { + data := `/dev/nvme0n1p2 / ext4 rw,relatime 0 0 +tmpfs /run tmpfs rw,nosuid,nodev 0 0 +/dev/sdb1 /media/loic/Photos\040SSD exfat rw,nosuid,nodev 0 0 +/dev/sdc1 /mnt/archive ext4 rw,relatime 0 0 +/dev/sdd1 /run/media/loic/USB vfat rw,nosuid,nodev 0 0 +` + + got := parseLinuxMounts(data) + if len(got) != 4 { + t.Fatalf("len=%d want 4 (%v)", len(got), got) + } + if got[0].mountPoint != "/" || got[0].portable { + t.Fatalf("root candidate = %+v", got[0]) + } + if got[1].mountPoint != "/media/loic/Photos SSD" || !got[1].portable { + t.Fatalf("media candidate = %+v", got[1]) + } + if got[2].mountPoint != "/mnt/archive" || !got[2].portable { + t.Fatalf("mnt candidate = %+v", got[2]) + } + if got[3].mountPoint != "/run/media/loic/USB" || !got[3].portable { + t.Fatalf("run-media candidate = %+v", got[3]) + } +} + +func TestUnescapeMountField(t *testing.T) { + if got := unescapeMountField(`/media/loic/Photos\040SSD`); got != "/media/loic/Photos SSD" { + t.Fatalf("got %q", got) + } + if got := unescapeMountField(`/plain/path`); got != "/plain/path" { + t.Fatalf("got %q", got) + } + if got := unescapeMountField(`/bad\99path`); got != `/bad\99path` { + t.Fatalf("got %q", got) + } +} diff --git a/internal/engine/source_discover_stub.go b/internal/engine/source_discover_stub.go new file mode 100644 index 0000000..b16ba72 --- /dev/null +++ b/internal/engine/source_discover_stub.go @@ -0,0 +1,7 @@ +//go:build !darwin && !linux && !windows + +package engine + +func discoverLocalCandidates() ([]discoverCandidate, error) { + return nil, nil +} diff --git a/internal/engine/source_discover_test.go b/internal/engine/source_discover_test.go new file mode 100644 index 0000000..f406959 --- /dev/null +++ b/internal/engine/source_discover_test.go @@ -0,0 +1,94 @@ +package engine + +import ( + "context" + "errors" + "testing" + + "github.com/cloudstic/cli/internal/core" +) + +func TestDiscoverSources_Error(t *testing.T) { + oldDiscover := discoverLocalCandidatesFunc + oldInfo := discoverLocalSourceInfoFunc + t.Cleanup(func() { + discoverLocalCandidatesFunc = oldDiscover + discoverLocalSourceInfoFunc = oldInfo + }) + + discoverLocalCandidatesFunc = func() ([]discoverCandidate, error) { + return nil, errors.New("boom") + } + + _, err := DiscoverSources(context.Background()) + if err == nil || err.Error() != "boom" { + t.Fatalf("err = %v, want boom", err) + } +} + +func TestDiscoverSources_NormalizesAndMergesCandidates(t *testing.T) { + oldDiscover := discoverLocalCandidatesFunc + oldInfo := discoverLocalSourceInfoFunc + t.Cleanup(func() { + discoverLocalCandidatesFunc = oldDiscover + discoverLocalSourceInfoFunc = oldInfo + }) + + discoverLocalCandidatesFunc = func() ([]discoverCandidate, error) { + return []discoverCandidate{ + {mountPoint: "/Volumes/Photos", portable: false}, + {mountPoint: "/Volumes/Photos/", portable: true}, + {mountPoint: "/", portable: false}, + {mountPoint: "", portable: true}, + }, nil + } + discoverLocalSourceInfoFunc = func(mountPoint string) core.SourceInfo { + switch mountPoint { + case "/": + return core.SourceInfo{DriveName: "", Identity: "HOST-1", PathID: "/", FsType: "apfs"} + case "/Volumes/Photos": + return core.SourceInfo{DriveName: "Photos", Identity: "UUID-1", PathID: "/", FsType: "exfat"} + default: + t.Fatalf("unexpected mountPoint %q", mountPoint) + return core.SourceInfo{} + } + } + + got, err := DiscoverSources(context.Background()) + if err != nil { + t.Fatalf("DiscoverSources: %v", err) + } + if len(got) != 2 { + t.Fatalf("len(got)=%d want 2", len(got)) + } + if got[0].MountPoint != "/" || got[0].DisplayName != "System" || got[0].Portable { + t.Fatalf("root result = %+v", got[0]) + } + if got[1].MountPoint != "/Volumes/Photos" || got[1].DisplayName != "Photos" || !got[1].Portable { + t.Fatalf("photos result = %+v", got[1]) + } + if got[1].SourceURI != "local:/Volumes/Photos" { + t.Fatalf("SourceURI=%q want local:/Volumes/Photos", got[1].SourceURI) + } +} + +func TestDiscoverDisplayName(t *testing.T) { + tests := []struct { + name string + mountPoint string + driveName string + want string + }{ + {name: "drive name wins", mountPoint: "/Volumes/Photos", driveName: "Portable SSD", want: "Portable SSD"}, + {name: "root", mountPoint: "/", want: "System"}, + {name: "base name", mountPoint: "/Volumes/Photos", want: "Photos"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := discoverDisplayName(tt.mountPoint, tt.driveName); got != tt.want { + t.Fatalf("got %q want %q", got, tt.want) + } + }) + } +} diff --git a/internal/engine/source_discover_windows.go b/internal/engine/source_discover_windows.go new file mode 100644 index 0000000..c35745b --- /dev/null +++ b/internal/engine/source_discover_windows.go @@ -0,0 +1,35 @@ +//go:build windows + +package engine + +import ( + "strings" + + "golang.org/x/sys/windows" +) + +func discoverLocalCandidates() ([]discoverCandidate, error) { + buf := make([]uint16, 256) + n, err := windows.GetLogicalDriveStrings(uint32(len(buf)), &buf[0]) + if err != nil { + return nil, err + } + + var candidates []discoverCandidate + for _, mountPoint := range strings.Split(windows.UTF16ToString(buf[:n]), "\x00") { + if mountPoint == "" { + continue + } + typ := windows.GetDriveType(windows.StringToUTF16Ptr(mountPoint)) + switch typ { + case windows.DRIVE_FIXED: + candidates = append(candidates, discoverCandidate{ + mountPoint: mountPoint, + portable: !strings.EqualFold(mountPoint, `C:\`), + }) + case windows.DRIVE_REMOVABLE: + candidates = append(candidates, discoverCandidate{mountPoint: mountPoint, portable: true}) + } + } + return candidates, nil +}