package updater import ( "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "time" "scout/pkg/client" "scout/pkg/scoutpkg" ) const ExitRestartNow = 42 type Config struct { Client *client.Client Adapter PlatformAdapter PackageName string CurrentVersion string CurrentPath string RootDir string Platform string Architecture string Channel string Entrypoint string HealthTimeout time.Duration KeepReleases int } type ReleasePointer struct { Version string `json:"version"` Path string `json:"path"` Entrypoint string `json:"entrypoint"` Platform string `json:"platform"` Architecture string `json:"architecture"` PackageType string `json:"package_type"` SHA256 string `json:"sha256"` PreparedAt time.Time `json:"prepared_at"` ActivatedAt time.Time `json:"activated_at,omitempty"` HealthyAt time.Time `json:"healthy_at,omitempty"` } type State struct { Stage string `json:"stage"` CurrentVersion string `json:"current_version,omitempty"` PreviousVersion string `json:"previous_version,omitempty"` Previous *ReleasePointer `json:"previous,omitempty"` PendingVersion string `json:"pending_version,omitempty"` ActivatedAt time.Time `json:"activated_at,omitempty"` Healthy bool `json:"healthy"` LastError string `json:"last_error,omitempty"` UpdatedAt time.Time `json:"updated_at"` } type Status struct { Current *ReleasePointer `json:"current,omitempty"` Pending *ReleasePointer `json:"pending,omitempty"` State State `json:"state"` } type Manager struct { config Config } func New(config Config) (*Manager, error) { if config.Client == nil { return nil, errors.New("\u5ba2\u6237\u7aef\u4e0d\u80fd\u4e3a\u7a7a") } if strings.TrimSpace(config.PackageName) == "" { return nil, errors.New("\u5305\u540d\u4e0d\u80fd\u4e3a\u7a7a") } if strings.TrimSpace(config.RootDir) == "" { return nil, errors.New("\u66f4\u65b0\u6839\u76ee\u5f55\u4e0d\u80fd\u4e3a\u7a7a") } platform, architecture := config.Platform, config.Architecture if platform == "" && architecture == "" { platform, architecture = scoutpkg.CurrentTarget() } else { var err error platform, err = scoutpkg.NormalizePlatform(platform) if err != nil { return nil, err } architecture, err = scoutpkg.NormalizeArchitecture(architecture) if err != nil { return nil, err } } config.Platform = platform config.Architecture = architecture if config.Channel == "" { config.Channel = "stable" } if config.HealthTimeout <= 0 { config.HealthTimeout = 2 * time.Minute } if config.KeepReleases <= 0 { config.KeepReleases = 2 } root, err := filepath.Abs(config.RootDir) if err != nil { return nil, fmt.Errorf("\u89e3\u6790\u66f4\u65b0\u6839\u76ee\u5f55\u5931\u8d25: %w", err) } config.RootDir = root if config.Adapter == nil { config.Adapter, err = NewPlatformAdapter(config.Platform) if err != nil { return nil, err } } for _, name := range []string{"releases", "downloads"} { if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { return nil, fmt.Errorf("\u521b\u5efa\u66f4\u65b0\u76ee\u5f55\u5931\u8d25: %w", err) } } manager := &Manager{config: config} if _, err := manager.readPointer("current.json"); os.IsNotExist(err) && config.CurrentVersion != "" && config.CurrentPath != "" && config.Entrypoint != "" { if _, err := manager.RegisterCurrent(config.CurrentVersion, config.CurrentPath, config.Entrypoint); err != nil { return nil, err } } return manager, nil } func (manager *Manager) RegisterCurrent(version, releasePath, entrypoint string) (ReleasePointer, error) { if strings.TrimSpace(version) == "" { return ReleasePointer{}, errors.New("\u5f53\u524d\u7248\u672c\u4e0d\u80fd\u4e3a\u7a7a") } releasePath, err := filepath.Abs(releasePath) if err != nil { return ReleasePointer{}, fmt.Errorf("\u89e3\u6790\u5f53\u524d\u7a0b\u5e8f\u76ee\u5f55\u5931\u8d25: %w", err) } executable, err := safeEntrypoint(releasePath, entrypoint) if err != nil { return ReleasePointer{}, err } if _, err := os.Stat(executable); err != nil { return ReleasePointer{}, fmt.Errorf("\u5f53\u524d\u7a0b\u5e8f\u5165\u53e3\u4e0d\u5b58\u5728: %w", err) } if err := manager.config.Adapter.Validate(ReleasePointer{ Version: version, Path: releasePath, Entrypoint: entrypoint, Platform: manager.config.Platform, Architecture: manager.config.Architecture, PackageType: "full", }); err != nil { return ReleasePointer{}, err } now := time.Now().UTC() pointer := ReleasePointer{ Version: version, Path: releasePath, Entrypoint: entrypoint, Platform: manager.config.Platform, Architecture: manager.config.Architecture, PackageType: "full", PreparedAt: now, ActivatedAt: now, HealthyAt: now, } if err := manager.writeJSON("current.json", pointer); err != nil { return ReleasePointer{}, err } state := State{Stage: "healthy", CurrentVersion: version, Healthy: true, UpdatedAt: now} if err := manager.writeJSON("state.json", state); err != nil { return ReleasePointer{}, err } return pointer, nil } func (manager *Manager) Check(ctx context.Context) (client.Package, bool, error) { currentVersion := manager.currentVersion() return manager.config.Client.CheckUpdateFor(ctx, manager.config.PackageName, currentVersion, manager.queryOptions(currentVersion)) } func (manager *Manager) Watch(ctx context.Context) (<-chan client.Update, error) { currentVersion := manager.currentVersion() return manager.config.Client.WatchUpdatesFor(ctx, client.WatchRequest{ Packages: []string{manager.config.PackageName}, Versions: map[string]string{manager.config.PackageName: currentVersion}, Platform: manager.config.Platform, Architecture: manager.config.Architecture, Channel: manager.config.Channel, }) } func (manager *Manager) Prepare(ctx context.Context, version string) (ReleasePointer, error) { if pending, err := manager.readPointer("pending.json"); err == nil { return ReleasePointer{}, fmt.Errorf("\u5df2\u6709\u5f85\u6fc0\u6d3b\u7248\u672c %s", pending.Version) } currentVersion := manager.currentVersion() options := manager.queryOptions(currentVersion) item, err := manager.resolvePackage(ctx, version, options) if err != nil { return ReleasePointer{}, err } if currentVersion != "" && client.CompareVersions(item.Version, currentVersion) <= 0 { return ReleasePointer{}, errors.New("\u76ee\u6807\u7248\u672c\u5fc5\u987b\u9ad8\u4e8e\u5f53\u524d\u7248\u672c") } releaseDir, err := manager.safeReleaseDir(item.Version) if err != nil { return ReleasePointer{}, err } if _, err := os.Stat(releaseDir); err == nil { return ReleasePointer{}, errors.New("\u76ee\u6807\u7248\u672c\u76ee\u5f55\u5df2\u7ecf\u5b58\u5728") } else if !os.IsNotExist(err) { return ReleasePointer{}, fmt.Errorf("\u68c0\u67e5\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err) } stagingDir, err := os.MkdirTemp(filepath.Dir(releaseDir), ".staging-"+item.Version+"-") if err != nil { return ReleasePointer{}, fmt.Errorf("\u521b\u5efa\u7248\u672c\u4e34\u65f6\u76ee\u5f55\u5931\u8d25: %w", err) } stagingReady := false defer func() { if !stagingReady { _ = os.RemoveAll(stagingDir) } }() fileName := item.FileName if fileName == "" && len(item.Targets) > 0 { fileName = item.Targets[0].FileName } if fileName == "" { fileName = fmt.Sprintf("%s-%s.pkg", item.Name, item.Version) } downloadPath := filepath.Join(manager.config.RootDir, "downloads", item.Version+"-"+filepath.Base(fileName)) downloaded, err := manager.config.Client.DownloadFor(ctx, item.Name, item.Version, downloadPath, options) if err != nil { return ReleasePointer{}, err } entrypoint := manager.config.Entrypoint if strings.EqualFold(filepath.Ext(fileName), ".scout") { entrypoint, err = manager.extractScout(downloadPath, stagingDir, entrypoint) if err != nil { return ReleasePointer{}, err } } else { destination := filepath.Join(stagingDir, filepath.Base(fileName)) if err := moveDownloadedFile(downloadPath, destination); err != nil { return ReleasePointer{}, err } if entrypoint == "" { entrypoint = filepath.Base(fileName) } } if entrypoint == "" { return ReleasePointer{}, errors.New("\u66f4\u65b0\u5305\u672a\u6307\u5b9a\u7a0b\u5e8f\u5165\u53e3") } executable, err := safeEntrypoint(stagingDir, entrypoint) if err != nil { return ReleasePointer{}, err } if _, err := os.Stat(executable); err != nil { return ReleasePointer{}, fmt.Errorf("\u65b0\u7248\u672c\u7a0b\u5e8f\u5165\u53e3\u4e0d\u5b58\u5728: %w", err) } if err := manager.config.Adapter.Validate(ReleasePointer{ Version: item.Version, Path: stagingDir, Entrypoint: entrypoint, Platform: manager.config.Platform, Architecture: manager.config.Architecture, PackageType: item.PackageType, SHA256: item.SHA256, }); err != nil { return ReleasePointer{}, err } if runtime.GOOS != "windows" && manager.config.Platform != "android" { if info, err := os.Stat(executable); err == nil && info.Mode().Perm()&0o111 == 0 { _ = os.Chmod(executable, info.Mode().Perm()|0o755) } } now := time.Now().UTC() pointer := ReleasePointer{ Version: item.Version, Path: releaseDir, Entrypoint: entrypoint, Platform: item.Platform, Architecture: item.Architecture, PackageType: item.PackageType, SHA256: downloaded.SHA256, PreparedAt: now, } if err := manager.writeReleaseMetadataAt(stagingDir, pointer); err != nil { return ReleasePointer{}, err } if err := os.Rename(stagingDir, releaseDir); err != nil { return ReleasePointer{}, fmt.Errorf("\u6fc0\u6d3b\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err) } stagingReady = true if err := manager.writeJSON("pending.json", pointer); err != nil { return ReleasePointer{}, err } state := State{ Stage: "prepared", CurrentVersion: currentVersion, PendingVersion: pointer.Version, Healthy: false, UpdatedAt: now, } if err := manager.writeJSON("state.json", state); err != nil { return ReleasePointer{}, err } return pointer, nil } func (manager *Manager) Activate() (ReleasePointer, error) { pending, err := manager.readPointer("pending.json") if err != nil { return ReleasePointer{}, errors.New("\u6ca1\u6709\u5f85\u6fc0\u6d3b\u7248\u672c") } var previous *ReleasePointer if current, err := manager.readPointer("current.json"); err == nil { previous = ¤t } now := time.Now().UTC() pending.ActivatedAt = now pending.HealthyAt = time.Time{} if err := manager.writeJSON("current.json", pending); err != nil { return ReleasePointer{}, err } state := State{ Stage: "activated", CurrentVersion: pending.Version, PendingVersion: pending.Version, ActivatedAt: now, Healthy: false, UpdatedAt: now, } if previous != nil { state.Previous = previous state.PreviousVersion = previous.Version } if err := manager.writeJSON("state.json", state); err != nil { return ReleasePointer{}, err } return pending, nil } // Install 将待激活版本交给平台适配器处理。 // 桌面平台通常只需要准备文件,Android 等平台由宿主应用注入系统安装实现。 func (manager *Manager) Install(ctx context.Context) error { pending, err := manager.readPointer("pending.json") if err != nil { if os.IsNotExist(err) { return errors.New("没有待安装版本,请先准备更新") } return err } return manager.config.Adapter.Install(ctx, pending) } func (manager *Manager) ConfirmHealthy(version string) error { current, err := manager.readPointer("current.json") if err != nil { return errors.New("\u5f53\u524d\u7248\u672c\u6307\u9488\u4e0d\u5b58\u5728") } if version != "" && current.Version != version { return errors.New("\u5065\u5eb7\u786e\u8ba4\u7248\u672c\u4e0e\u5f53\u524d\u7248\u672c\u4e0d\u4e00\u81f4") } state, err := manager.readState() if err != nil { return err } if state.Stage != "activated" || state.CurrentVersion != current.Version { return errors.New("\u5f53\u524d\u72b6\u6001\u4e0d\u5141\u8bb8\u5065\u5eb7\u786e\u8ba4") } now := time.Now().UTC() current.HealthyAt = now if err := manager.writeJSON("current.json", current); err != nil { return err } state.Stage = "healthy" state.PendingVersion = "" state.Healthy = true state.UpdatedAt = now if err := manager.writeJSON("state.json", state); err != nil { return err } _ = os.Remove(manager.filePath("pending.json")) return manager.Cleanup() } func (manager *Manager) Rollback(reason error) (ReleasePointer, error) { state, err := manager.readState() if err != nil { return ReleasePointer{}, err } if state.Previous == nil { return ReleasePointer{}, errors.New("\u6ca1\u6709\u53ef\u56de\u6eda\u7684\u65e7\u7248\u672c\u6307\u9488") } previous := *state.Previous if _, err := safeEntrypoint(previous.Path, previous.Entrypoint); err != nil { return ReleasePointer{}, err } if err := manager.writeJSON("current.json", previous); err != nil { return ReleasePointer{}, err } state.Stage = "rolled_back" state.CurrentVersion = previous.Version state.PendingVersion = "" state.Healthy = true if reason != nil { state.LastError = reason.Error() } state.UpdatedAt = time.Now().UTC() if err := manager.writeJSON("state.json", state); err != nil { return ReleasePointer{}, err } _ = os.Remove(manager.filePath("pending.json")) return previous, nil } func (manager *Manager) RecoverStaleActivation(now time.Time) (bool, error) { state, err := manager.readState() if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } if state.Stage != "activated" || state.Healthy { return false, nil } if now.Before(state.ActivatedAt.Add(manager.config.HealthTimeout)) { return false, nil } if state.Previous == nil { return false, errors.New("\u5065\u5eb7\u786e\u8ba4\u8d85\u65f6\uff0c\u4f46\u6ca1\u6709\u53ef\u56de\u6eda\u7684\u65e7\u7248\u672c") } _, rollbackErr := manager.Rollback(errors.New("\u65b0\u7248\u672c\u5065\u5eb7\u786e\u8ba4\u8d85\u65f6\uff0c\u5df2\u81ea\u52a8\u56de\u6eda")) return rollbackErr == nil, rollbackErr } func (manager *Manager) Status() (Status, error) { result := Status{} if current, err := manager.readPointer("current.json"); err == nil { result.Current = ¤t } else if !os.IsNotExist(err) { return Status{}, err } if pending, err := manager.readPointer("pending.json"); err == nil { result.Pending = &pending } else if !os.IsNotExist(err) { return Status{}, err } state, err := manager.readState() if err != nil && !os.IsNotExist(err) { return Status{}, err } result.State = state return result, nil } func (manager *Manager) ResolveCurrent() (string, ReleasePointer, error) { current, err := manager.readPointer("current.json") if err != nil { return "", ReleasePointer{}, errors.New("\u5f53\u524d\u7248\u672c\u6307\u9488\u4e0d\u5b58\u5728") } executable, err := safeEntrypoint(current.Path, current.Entrypoint) if err != nil { return "", ReleasePointer{}, err } if _, err := os.Stat(executable); err != nil { return "", ReleasePointer{}, fmt.Errorf("\u5f53\u524d\u7a0b\u5e8f\u5165\u53e3\u4e0d\u5b58\u5728: %w", err) } return executable, current, nil } func (manager *Manager) Launch(ctx context.Context, args ...string) (*exec.Cmd, error) { _, current, err := manager.ResolveCurrent() if err != nil { return nil, err } return manager.config.Adapter.Launch(ctx, LaunchRequest{ Release: current, Args: args, Env: append(os.Environ(), "SCOUT_PACKAGE="+manager.config.PackageName, "SCOUT_VERSION="+current.Version, "SCOUT_UPDATE_ROOT="+manager.config.RootDir, "SCOUT_PLATFORM="+manager.config.Platform, "SCOUT_ARCHITECTURE="+manager.config.Architecture, ), }) } func (manager *Manager) Cleanup() error { releasesDir := filepath.Join(manager.config.RootDir, "releases") entries, err := os.ReadDir(releasesDir) if err != nil { return fmt.Errorf("\u8bfb\u53d6\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err) } status, err := manager.Status() if err != nil { return err } protected := make(map[string]bool) if status.Current != nil && isManagedRelease(releasesDir, status.Current.Path) { protected[status.Current.Version] = true } if status.Pending != nil && isManagedRelease(releasesDir, status.Pending.Path) { protected[status.Pending.Version] = true } candidates := make([]os.DirEntry, 0, len(entries)) for _, entry := range entries { if entry.IsDir() && !protected[entry.Name()] { candidates = append(candidates, entry) } } sort.Slice(candidates, func(left, right int) bool { leftInfo, _ := candidates[left].Info() rightInfo, _ := candidates[right].Info() return leftInfo.ModTime().After(rightInfo.ModTime()) }) keepCandidates := manager.config.KeepReleases - len(protected) if keepCandidates < 0 { keepCandidates = 0 } for index, entry := range candidates { if index < keepCandidates { continue } target, err := manager.safeReleaseDir(entry.Name()) if err != nil { return err } if err := os.RemoveAll(target); err != nil { return fmt.Errorf("\u6e05\u7406\u65e7\u7248\u672c\u5931\u8d25: %w", err) } } return nil } func (manager *Manager) resolvePackage(ctx context.Context, version string, options client.QueryOptions) (client.Package, error) { if strings.TrimSpace(version) == "" { return manager.config.Client.GetLatestFor(ctx, manager.config.PackageName, options) } versions, err := manager.config.Client.ListVersionsFor(ctx, manager.config.PackageName, options) if err != nil { return client.Package{}, err } for _, item := range versions { if item.Version == version { return item, nil } } return client.Package{}, errors.New("\u6ca1\u6709\u627e\u5230\u6307\u5b9a\u7684\u517c\u5bb9\u7248\u672c") } func (manager *Manager) extractScout(downloadPath, releaseDir, fallbackEntrypoint string) (string, error) { archive, err := scoutpkg.Open(downloadPath) if err != nil { return "", err } target, extractErr := archive.Extract(manager.config.Platform, manager.config.Architecture, releaseDir) closeErr := archive.Close() if extractErr != nil { return "", extractErr } if closeErr != nil { return "", closeErr } if target.Entrypoint == "" { return fallbackEntrypoint, nil } relative := strings.TrimPrefix(target.Entrypoint, strings.TrimSuffix(target.Path, "/")) relative = strings.TrimPrefix(relative, "/") if relative == "" { relative = filepath.Base(filepath.FromSlash(target.Path)) } return filepath.FromSlash(relative), nil } func (manager *Manager) queryOptions(currentVersion string) client.QueryOptions { return client.QueryOptions{ Platform: manager.config.Platform, Architecture: manager.config.Architecture, Channel: manager.config.Channel, CurrentVersion: currentVersion, } } func (manager *Manager) currentVersion() string { if current, err := manager.readPointer("current.json"); err == nil && current.Version != "" { return current.Version } return manager.config.CurrentVersion } func (manager *Manager) writeReleaseMetadata(pointer ReleasePointer) error { return manager.writeReleaseMetadataAt(pointer.Path, pointer) } func (manager *Manager) writeReleaseMetadataAt(directory string, pointer ReleasePointer) error { data, err := json.MarshalIndent(pointer, "", " ") if err != nil { return fmt.Errorf("\u7f16\u7801\u7248\u672c\u5143\u6570\u636e\u5931\u8d25: %w", err) } if err := os.WriteFile(filepath.Join(directory, "scout-release.json"), data, 0o644); err != nil { return fmt.Errorf("\u4fdd\u5b58\u7248\u672c\u5143\u6570\u636e\u5931\u8d25: %w", err) } return nil } func (manager *Manager) writeJSON(name string, value any) error { data, err := json.MarshalIndent(value, "", " ") if err != nil { return fmt.Errorf("\u7f16\u7801\u66f4\u65b0\u72b6\u6001\u5931\u8d25: %w", err) } temp, err := os.CreateTemp(manager.config.RootDir, ".state-*") if err != nil { return fmt.Errorf("\u521b\u5efa\u72b6\u6001\u4e34\u65f6\u6587\u4ef6\u5931\u8d25: %w", err) } tempPath := temp.Name() defer os.Remove(tempPath) if _, err := temp.Write(data); err != nil { temp.Close() return fmt.Errorf("\u5199\u5165\u66f4\u65b0\u72b6\u6001\u5931\u8d25: %w", err) } if err := temp.Close(); err != nil { return fmt.Errorf("\u5173\u95ed\u72b6\u6001\u4e34\u65f6\u6587\u4ef6\u5931\u8d25: %w", err) } if err := os.Rename(tempPath, manager.filePath(name)); err != nil { return fmt.Errorf("\u4fdd\u5b58\u66f4\u65b0\u72b6\u6001\u5931\u8d25: %w", err) } return nil } func (manager *Manager) readPointer(name string) (ReleasePointer, error) { data, err := os.ReadFile(manager.filePath(name)) if err != nil { return ReleasePointer{}, err } var pointer ReleasePointer if err := json.Unmarshal(data, &pointer); err != nil { return ReleasePointer{}, fmt.Errorf("\u8bfb\u53d6\u7248\u672c\u6307\u9488\u5931\u8d25: %w", err) } return pointer, nil } func (manager *Manager) readState() (State, error) { data, err := os.ReadFile(manager.filePath("state.json")) if err != nil { return State{}, err } var state State if err := json.Unmarshal(data, &state); err != nil { return State{}, fmt.Errorf("\u8bfb\u53d6\u66f4\u65b0\u72b6\u6001\u5931\u8d25: %w", err) } return state, nil } func (manager *Manager) safeReleaseDir(version string) (string, error) { version = strings.TrimSpace(version) if version == "" || version == "." || version == ".." || filepath.Base(version) != version || strings.ContainsAny(version, `/\\`) { return "", errors.New("\u7248\u672c\u76ee\u5f55\u540d\u65e0\u6548") } releasesDir := filepath.Join(manager.config.RootDir, "releases") target := filepath.Join(releasesDir, version) if !isManagedRelease(releasesDir, target) { return "", errors.New("\u7248\u672c\u76ee\u5f55\u8d85\u51fa\u66f4\u65b0\u6839\u76ee\u5f55") } return target, nil } func (manager *Manager) filePath(name string) string { return filepath.Join(manager.config.RootDir, name) } func safeEntrypoint(releaseDir, entrypoint string) (string, error) { if strings.TrimSpace(entrypoint) == "" { return "", errors.New("\u7a0b\u5e8f\u5165\u53e3\u4e0d\u80fd\u4e3a\u7a7a") } releaseDir, err := filepath.Abs(releaseDir) if err != nil { return "", fmt.Errorf("\u89e3\u6790\u7248\u672c\u76ee\u5f55\u5931\u8d25: %w", err) } executable := filepath.Join(releaseDir, filepath.FromSlash(entrypoint)) relation, err := filepath.Rel(releaseDir, executable) if err != nil || relation == ".." || strings.HasPrefix(relation, ".."+string(filepath.Separator)) || filepath.IsAbs(entrypoint) { return "", errors.New("\u7a0b\u5e8f\u5165\u53e3\u8d85\u51fa\u7248\u672c\u76ee\u5f55") } return executable, nil } func moveDownloadedFile(source, destination string) error { if err := os.Rename(source, destination); err == nil { return nil } input, err := os.Open(source) if err != nil { return fmt.Errorf("\u8bfb\u53d6\u5df2\u4e0b\u8f7d\u6587\u4ef6\u5931\u8d25: %w", err) } defer input.Close() output, err := os.Create(destination) if err != nil { return fmt.Errorf("\u521b\u5efa\u7248\u672c\u6587\u4ef6\u5931\u8d25: %w", err) } _, copyErr := io.Copy(output, input) closeErr := output.Close() if copyErr != nil { return fmt.Errorf("\u590d\u5236\u7248\u672c\u6587\u4ef6\u5931\u8d25: %w", copyErr) } if closeErr != nil { return fmt.Errorf("\u5173\u95ed\u7248\u672c\u6587\u4ef6\u5931\u8d25: %w", closeErr) } return nil } func isManagedRelease(releasesDir, candidate string) bool { releasesDir, err := filepath.Abs(releasesDir) if err != nil { return false } candidate, err = filepath.Abs(candidate) if err != nil { return false } relation, err := filepath.Rel(releasesDir, candidate) return err == nil && relation != "." && relation != ".." && !strings.HasPrefix(relation, ".."+string(filepath.Separator)) }