package setup import ( "bufio" "bytes" "crypto/rand" "encoding/hex" "errors" "fmt" "io" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "unicode" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/safeio" "gopkg.in/yaml.v3" ) const ( descriptorName = "thothii-installation.yaml" environmentName = "operator.env" maxSecretBytes = 64 << 10 ) var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) var secretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`) // atomicWriteNewFile is a seam for failure testing. Its implementation never replaces an existing // file and leaves no final target until all content is synced. var atomicWriteNewFile = writeNewFileAtomically // effectiveUID is a package-private seam so projected server setup can prove its root gate // happens before any filesystem mutation. var effectiveUID = currentEffectiveUID type answers struct { shell config.Shell installationID, profile string workspaceRemote, workspaceBranch string workspaceAccess string dwhRESTURL, llmURL string secretsFile, piAuthFile string gitCredentialsFile, gitCAFile string gitSSHKeyFile, gitKnownHostsFile string complete bool createSecretTemplates bool } type generatedDescriptor struct { SchemaVersion int `yaml:"schemaVersion"` Profile string `yaml:"profile"` ProjectDirectory string `yaml:"projectDirectory"` EnvFile string `yaml:"envFile"` Workspace struct { Remote string `yaml:"remote"` Branch string `yaml:"branch"` Access string `yaml:"access"` } `yaml:"workspaceRepository"` Authentication struct { ConfigDirectory string `yaml:"configDirectory"` RuntimeProjection *struct { Directory string `yaml:"directory"` UID uint32 `yaml:"uid"` GID uint32 `yaml:"gid"` } `yaml:"runtimeProjection,omitempty"` } `yaml:"authentication"` ModelCatalog config.ModelCatalog `yaml:"modelCatalog"` Shell config.Shell `yaml:"shell,omitempty"` Overrides []string `yaml:"overrides"` } // EnsureFiles writes a descriptor and non-secret environment file below deploy/. // Existing files are accepted only when their bytes exactly match the requested configuration. func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResult, error) { root, err := canonicalProjectRoot(request.ProjectRoot) if err != nil { return FilesResult{}, err } values, err := collectAnswers(request, input, output, root) if err != nil { return FilesResult{}, err } if err := validateAnswers(values); err != nil { return FilesResult{}, err } if values.profile == "server" && effectiveUID() != 0 { return FilesResult{}, errors.New("projected server setup requires root") } directory, err := installationDirectory(root, values.installationID) if err != nil { return FilesResult{}, err } descriptorPath := filepath.Join(directory, descriptorName) environmentPath := filepath.Join(directory, environmentName) if values.profile == "server" { if err := ensureProjectedAuthDirectories(filepath.Join(directory, "auth"), filepath.Join(directory, "auth-runtime")); err != nil { return FilesResult{}, errors.New("projected authentication directories are unavailable or unsafe") } } else if err := safeio.EnsurePrivateDirectory(filepath.Join(directory, "auth")); err != nil { return FilesResult{}, errors.New("authentication directory is unavailable or unsafe") } result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath} descriptor, environment, err := render(root, descriptorPath, values) if err != nil { return FilesResult{}, err } if err := requireCompatibleOrAbsent(descriptorPath, descriptor); err != nil { return FilesResult{}, err } if err := requireCompatibleOrAbsent(environmentPath, environment); err != nil { return FilesResult{}, err } if err := validateOrCreateSecretFiles(values, output); err != nil { return FilesResult{}, err } if values.complete { if err := validateCompleteProtectedFiles(values); err != nil { return FilesResult{}, err } } created := make([]string, 0, 2) cleanup := func() { for index := len(created) - 1; index >= 0; index-- { _ = os.Remove(created[index]) } } if err := writeIfAbsent(descriptorPath, descriptor, &created); err != nil { cleanup() return FilesResult{}, err } if err := writeIfAbsent(environmentPath, environment, &created); err != nil { cleanup() return FilesResult{}, err } result.Created = created return result, nil } func canonicalProjectRoot(path string) (string, error) { if strings.TrimSpace(path) == "" { return "", errors.New("setup requires the current ThothII project root") } if !filepath.IsAbs(path) || filepath.Clean(path) != path { return "", errors.New("setup project root must be an absolute canonical path") } resolved, err := filepath.EvalSymlinks(path) if err != nil || resolved != path { return "", errors.New("setup project root is unavailable or contains a symlink") } for _, required := range []string{filepath.Join(path, "compose.yaml"), filepath.Join(path, "deploy")} { info, statErr := os.Stat(required) if statErr != nil || (filepath.Base(required) == "deploy" && !info.IsDir()) || (filepath.Base(required) != "deploy" && !info.Mode().IsRegular()) { return "", errors.New("setup project root is not a ThothII checkout") } } deployInfo, err := os.Lstat(filepath.Join(path, "deploy")) if err != nil || deployInfo.Mode()&os.ModeSymlink != 0 { return "", errors.New("setup project deployment directory is unavailable or contains a symlink") } return path, nil } func collectAnswers(request Request, input io.Reader, output io.Writer, root string) (answers, error) { value := answersFromRequest(request) value.installationID = firstNonEmpty(request.InstallationID, os.Getenv("THT_SETUP_INSTALLATION_ID"), "local") value.profile = firstNonEmpty(request.Profile, os.Getenv("THT_SETUP_PROFILE"), "local") value.complete = request.Complete if request.Complete { // The complete path has one predictable protected directory. The user only fills the // bundle and any repository credential that is genuinely required; catalog passwords // are generated below and never appear in the questionnaire. directory := filepath.Join(root, "deploy", value.installationID, "secrets") value.workspaceBranch = firstNonEmpty(value.workspaceBranch, "main") value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets")) value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json")) if request.NonInteractive { value.workspaceAccess = firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote)) if value.workspaceAccess == "ssh" { value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key")) value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts")) } else { value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials")) value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem")) } } value.createSecretTemplates = true } if request.NonInteractive { return requireNonInteractiveAnswers(value) } scanner := bufio.NewScanner(input) var err error if value.installationID, err = prompt(scanner, output, "Installation ID", value.installationID); err != nil { return answers{}, err } if value.profile, err = prompt(scanner, output, "Deployment profile (local or server)", value.profile); err != nil { return answers{}, err } if value.dwhRESTURL, err = prompt(scanner, output, "DWH API endpoint (optional)", value.dwhRESTURL); err != nil { return answers{}, err } if value.llmURL, err = prompt(scanner, output, "LLM API endpoint (optional)", value.llmURL); err != nil { return answers{}, err } if value.workspaceRemote, err = prompt(scanner, output, "Workspace repository URL", firstNonEmpty(value.workspaceRemote, "https://git.example.invalid/thothii-workspaces.git")); err != nil { return answers{}, err } if value.workspaceBranch, err = prompt(scanner, output, "Workspace repository branch", firstNonEmpty(value.workspaceBranch, "main")); err != nil { return answers{}, err } if value.workspaceAccess, err = prompt(scanner, output, "Workspace repository access (https or ssh)", firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote))); err != nil { return answers{}, err } directory := filepath.Join(root, "deploy", value.installationID, "secrets") if request.Complete { value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets")) value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json")) if value.workspaceAccess == "ssh" { value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key")) value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts")) } else { value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials")) value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem")) } return value, nil } if value.secretsFile, err = prompt(scanner, output, "Secret file location", firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))); err != nil { return answers{}, err } if value.piAuthFile, err = prompt(scanner, output, "Pi credentials file location", firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))); err != nil { return answers{}, err } if value.workspaceAccess == "ssh" { if value.gitSSHKeyFile, err = prompt(scanner, output, "Workspace Git SSH key location", firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))); err != nil { return answers{}, err } if value.gitKnownHostsFile, err = prompt(scanner, output, "Workspace Git known-hosts location", firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))); err != nil { return answers{}, err } } else { if value.gitCredentialsFile, err = prompt(scanner, output, "Workspace Git credentials file location", firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))); err != nil { return answers{}, err } if value.gitCAFile, err = prompt(scanner, output, "Workspace Git CA file location", firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))); err != nil { return answers{}, err } } missing, missingErr := missingSecretFiles(value) if missingErr != nil { return answers{}, missingErr } if len(missing) > 0 { if request.Complete { value.createSecretTemplates = true } else { answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no") if promptErr != nil { return answers{}, promptErr } value.createSecretTemplates = strings.EqualFold(answer, "yes") } } return value, nil } func answersFromRequest(request Request) answers { answer := request.Answers return answers{ shell: config.Shell{ Mode: firstNonEmpty(answer.ShellMode, os.Getenv("THT_SETUP_SHELL_MODE")), DefaultLocale: firstNonEmpty(answer.ShellDefaultLocale, os.Getenv("THT_SETUP_SHELL_DEFAULT_LOCALE")), Adapter: firstNonEmpty(answer.ShellAdapter, os.Getenv("THT_SETUP_SHELL_ADAPTER")), }, workspaceRemote: firstNonEmpty(answer.WorkspaceRemote, os.Getenv("THT_SETUP_WORKSPACE_REMOTE")), workspaceBranch: firstNonEmpty(answer.WorkspaceBranch, os.Getenv("THT_SETUP_WORKSPACE_BRANCH")), workspaceAccess: firstNonEmpty(answer.WorkspaceAccess, os.Getenv("THT_SETUP_WORKSPACE_ACCESS")), dwhRESTURL: firstNonEmpty(answer.DWHRESTURL, os.Getenv("THT_SETUP_DWH_REST_URL")), llmURL: firstNonEmpty(answer.LLMURL, os.Getenv("THT_SETUP_LLM_URL")), secretsFile: firstNonEmpty(answer.SecretsFile, os.Getenv("THT_SETUP_SECRETS_FILE")), piAuthFile: firstNonEmpty(answer.PiAuthFile, os.Getenv("THT_SETUP_PI_AUTH_FILE")), gitCredentialsFile: firstNonEmpty(answer.GitCredentialsFile, os.Getenv("THT_SETUP_GIT_CREDENTIALS_FILE")), gitCAFile: firstNonEmpty(answer.GitCAFile, os.Getenv("THT_SETUP_GIT_CA_FILE")), gitSSHKeyFile: firstNonEmpty(answer.GitSSHKeyFile, os.Getenv("THT_SETUP_GIT_SSH_KEY_FILE")), gitKnownHostsFile: firstNonEmpty(answer.GitKnownHostsFile, os.Getenv("THT_SETUP_GIT_KNOWN_HOSTS_FILE")), createSecretTemplates: answer.CreateSecretTemplates, } } func requireNonInteractiveAnswers(value answers) (answers, error) { required := []struct{ name, value string }{ {"THT_SETUP_WORKSPACE_REMOTE", value.workspaceRemote}, {"THT_SETUP_WORKSPACE_BRANCH", value.workspaceBranch}, {"THT_SETUP_WORKSPACE_ACCESS", value.workspaceAccess}, {"THT_SETUP_SECRETS_FILE", value.secretsFile}, {"THT_SETUP_PI_AUTH_FILE", value.piAuthFile}, } if value.workspaceAccess == "ssh" { required = append(required, struct{ name, value string }{"THT_SETUP_GIT_SSH_KEY_FILE", value.gitSSHKeyFile}, struct{ name, value string }{"THT_SETUP_GIT_KNOWN_HOSTS_FILE", value.gitKnownHostsFile}) } else if value.workspaceAccess == "https" { required = append(required, struct{ name, value string }{"THT_SETUP_GIT_CREDENTIALS_FILE", value.gitCredentialsFile}, struct{ name, value string }{"THT_SETUP_GIT_CA_FILE", value.gitCAFile}) } for _, requiredValue := range required { if strings.TrimSpace(requiredValue.value) == "" { return answers{}, fmt.Errorf("non-interactive setup requires %s or its matching setup flag", requiredValue.name) } } return value, nil } func prompt(scanner *bufio.Scanner, output io.Writer, question, defaultValue string) (string, error) { fmt.Fprintf(output, "%s [%s]: ", question, defaultValue) if !scanner.Scan() { return "", fmt.Errorf("setup input ended while waiting for %s", strings.ToLower(question)) } value := strings.TrimSpace(scanner.Text()) if value == "" { return defaultValue, nil } return value, nil } func validateAnswers(value answers) error { if err := value.shell.NormalizeDefaults(); err != nil { return err } if !installationIDPattern.MatchString(value.installationID) { return errors.New("installation ID must contain only letters, numbers, dashes, and underscores") } if value.profile != "local" && value.profile != "server" { return errors.New("deployment profile must be local or server") } if value.workspaceAccess != "ssh" && value.workspaceAccess != "https" { return errors.New("workspace repository access must be ssh or https") } for name, endpoint := range map[string]string{"DWH API": value.dwhRESTURL, "LLM API": value.llmURL} { if err := validateServiceEndpoint(name, endpoint); err != nil { return err } } for name, path := range map[string]string{ "secret file location": value.secretsFile, "Pi credentials file location": value.piAuthFile, "workspace Git credentials file location": value.gitCredentialsFile, "workspace Git CA file location": value.gitCAFile, "workspace Git SSH key location": value.gitSSHKeyFile, "workspace Git known-hosts location": value.gitKnownHostsFile, } { if path == "" && ((value.workspaceAccess == "ssh" && (name == "workspace Git credentials file location" || name == "workspace Git CA file location")) || (value.workspaceAccess == "https" && (name == "workspace Git SSH key location" || name == "workspace Git known-hosts location"))) { continue } if err := safeio.ValidateCanonicalPath(path); err != nil { return fmt.Errorf("%s must be an absolute canonical path", name) } } return nil } func installationDirectory(root, id string) (string, error) { directory := filepath.Join(root, "deploy", id) if err := safeio.ValidateCanonicalPath(directory); err != nil { return "", errors.New("installation directory is unsafe") } if info, err := os.Lstat(directory); err == nil { if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return "", fmt.Errorf("installation directory %s is unavailable or unsafe", directory) } return directory, nil } else if !errors.Is(err, os.ErrNotExist) { return "", fmt.Errorf("installation directory %s could not be inspected", directory) } if err := os.Mkdir(directory, 0o700); err != nil { return "", fmt.Errorf("create installation directory %s: %w", directory, err) } return directory, nil } func render(root, descriptorPath string, value answers) ([]byte, []byte, error) { descriptor := generatedDescriptor{SchemaVersion: 2, Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName), ModelCatalog: defaultModelCatalog()} // Preserve the legacy descriptor bytes when no shell setting was requested. if value.shell != (config.Shell{}) { if err := value.shell.NormalizeDefaults(); err != nil { return nil, nil, err } descriptor.Shell = value.shell } descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess descriptor.Authentication.ConfigDirectory = filepath.Join(filepath.Dir(descriptorPath), "auth") if value.profile == "server" { descriptor.Authentication.RuntimeProjection = &struct { Directory string `yaml:"directory"` UID uint32 `yaml:"uid"` GID uint32 `yaml:"gid"` }{ Directory: filepath.Join(filepath.Dir(descriptorPath), "auth-runtime"), UID: 10001, GID: 10001, } } descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")} descriptorBytes, err := yaml.Marshal(descriptor) if err != nil { return nil, nil, err } lines := []string{ "# Generated by tht setup. This file contains locations, never secret values.", "THT_WORKSPACE_GIT_REMOTE=" + dotenvValue(value.workspaceRemote), "THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch), "THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID), "THT_AUTH_CONFIG_ROOT=" + dotenvValue(descriptor.Authentication.ConfigDirectory), "THT_INSTALLATION_CONFIG_SOURCE=" + dotenvValue(descriptorPath), "THT_SECRETS_FILE=" + dotenvValue(value.secretsFile), "PI_AUTH_FILE=" + dotenvValue(value.piAuthFile), "THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4", } if value.workspaceAccess == "ssh" { lines = append(lines, "THT_WORKSPACE_GIT_SSH_KEY_FILE="+dotenvValue(value.gitSSHKeyFile), "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE="+dotenvValue(value.gitKnownHostsFile)) } else { lines = append(lines, "THT_WORKSPACE_GIT_CREDENTIALS_FILE="+dotenvValue(value.gitCredentialsFile), "THT_WORKSPACE_GIT_CA_FILE="+dotenvValue(value.gitCAFile)) } if value.dwhRESTURL != "" { lines = append(lines, "THT_DWH_REST_URL="+dotenvValue(value.dwhRESTURL)) } if value.llmURL != "" { lines = append(lines, "THT_LLM_URL="+dotenvValue(value.llmURL)) } if value.complete { passwordDirectory := filepath.Dir(value.secretsFile) lines = append(lines, "THT_CATALOG_RUNTIME_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-runtime-password")), "THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-migrator-password")), ) } if value.profile == "server" { installationDirectory := filepath.Dir(descriptorPath) lines = append(lines, "THT_AUTH_RUNTIME_ROOT="+dotenvValue(descriptor.Authentication.RuntimeProjection.Directory), "THT_DATA_ROOT="+dotenvValue(filepath.Join(installationDirectory, "data")), "THT_PI_STATE_ROOT="+dotenvValue(filepath.Join(installationDirectory, "pi-state")), "THT_WORKSPACE_REGISTRY_ROOT="+dotenvValue(filepath.Join(installationDirectory, "workspace-registry")), "THT_BACKUP_ROOT="+dotenvValue(filepath.Join(installationDirectory, "backups")), ) } return descriptorBytes, []byte(strings.Join(lines, "\n") + "\n"), nil } func defaultModelCatalog() config.ModelCatalog { return config.ModelCatalog{ Defaults: config.ModelCatalogDefaults{Interaction: "deepseek/deepseek-v4-pro"}, Embedding: config.ModelCatalogEmbedding{ID: "ollama/qwen3-embedding:0.6b", Dimensions: 1024}, Providers: map[string]config.ModelProvider{ "deepseek": { Authentication: config.ModelAuthentication{Mode: "pi_auth"}, Session: &config.ModelSessionAdapter{Mode: "pi_builtin"}, Models: map[string]config.CatalogModel{ "deepseek-v4-pro": {Session: &config.SessionModel{}}, }, }, }, } } func dotenvValue(value string) string { return strconv.Quote(value) } func requireCompatibleOrAbsent(path string, expected []byte) error { info, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fmt.Errorf("configuration file %s is unsafe; choose a different installation ID or remove the unsafe file", path) } actual, err := os.ReadFile(path) if err != nil || !bytes.Equal(actual, expected) { return fmt.Errorf("configuration file %s already exists with different content; choose a different installation ID or move that file before running setup", path) } return nil } func writeIfAbsent(path string, contents []byte, created *[]string) error { if _, err := os.Lstat(path); err == nil { if err := requireCompatibleOrAbsent(path, contents); err != nil { return err } return nil } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("inspect configuration file %s: %w", path, err) } if err := atomicWriteNewFile(path, contents, 0o600); err != nil { return fmt.Errorf("write configuration file %s: %w", path, err) } *created = append(*created, path) return nil } func validateServiceEndpoint(name, endpoint string) error { if endpoint == "" { return nil } parsed, err := url.Parse(endpoint) if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return fmt.Errorf("%s endpoint must be an http(s) URL without user information, password, query, or fragment", name) } return nil } func missingSecretFiles(value answers) ([]string, error) { paths := configuredSecretPaths(value) missing := make([]string, 0, len(paths)) for _, path := range paths { exists, err := inspectExistingSecretFile(path) if err != nil { return nil, err } if !exists { missing = append(missing, path) } } sort.Strings(missing) return missing, nil } func validateOrCreateSecretFiles(value answers, output io.Writer) error { missing, err := missingSecretFiles(value) if err != nil { return err } if len(missing) > 0 && !value.createSecretTemplates { return fmt.Errorf("secret files are missing: %s; create them yourself or explicitly confirm blank secret-file templates", strings.Join(missing, ", ")) } for _, path := range missing { if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("create secret-template directory: %w", err) } if err := atomicWriteNewFile(path, secretTemplate(path), 0o600); err != nil { return fmt.Errorf("create secret-file template %s: %w", path, err) } fmt.Fprintf(output, "Created blank secret-file template: %s\n", path) } if value.complete { for _, path := range catalogPasswordPaths(value) { exists, err := inspectExistingSecretFile(path) if err != nil { return err } if exists { continue } contents, err := generatedCatalogPassword() if err != nil { return fmt.Errorf("generate catalog password: %w", err) } if err := atomicWriteNewFile(path, contents, 0o600); err != nil { return fmt.Errorf("create catalog password %s: %w", path, err) } fmt.Fprintf(output, "Created generated catalog password file: %s\n", path) } } return nil } func catalogPasswordPaths(value answers) []string { directory := filepath.Dir(value.secretsFile) return []string{ filepath.Join(directory, "catalog-runtime-password"), filepath.Join(directory, "catalog-migrator-password"), } } func generatedCatalogPassword() ([]byte, error) { value := make([]byte, 32) if _, err := rand.Read(value); err != nil { return nil, errors.New("secure random source is unavailable") } return []byte(hex.EncodeToString(value) + "\n"), nil } func validateCompleteProtectedFiles(value answers) error { if err := validateSecretBundle(value.secretsFile); err != nil { return err } // The generated catalog deliberately uses Pi's built-in provider. A syntactically empty // auth store would let Docker start only to fail at the first provider check, so catch it // before any image is built. Other model providers can be selected later in the descriptor. contents, err := safeio.ReadCanonicalRegular(value.piAuthFile, maxSecretBytes) if err != nil || strings.TrimSpace(string(contents)) == "" || strings.TrimSpace(string(contents)) == "{}" { return fmt.Errorf("complete setup requires usable Pi credentials in %s", value.piAuthFile) } if value.workspaceAccess == "ssh" { for name, path := range map[string]string{ "workspace Git SSH key": value.gitSSHKeyFile, "workspace Git known-hosts": value.gitKnownHostsFile, } { contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes) if readErr != nil || strings.TrimSpace(string(contents)) == "" { return fmt.Errorf("complete setup requires usable %s in %s", name, path) } } } else { for name, path := range map[string]string{ "workspace Git credentials": value.gitCredentialsFile, "workspace Git CA": value.gitCAFile, } { contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes) if readErr != nil || strings.TrimSpace(string(contents)) == "" { return fmt.Errorf("complete setup requires usable %s in %s", name, path) } } } return nil } func validateSecretBundle(path string) error { contents, err := safeio.ReadCanonicalRegular(path, maxSecretBytes) if err != nil { return fmt.Errorf("complete setup cannot read the secret bundle %s", path) } seen := make(map[string]struct{}) for lineNumber, raw := range strings.Split(string(contents), "\n") { line := strings.TrimSuffix(raw, "\r") trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { continue } key, secret, found := strings.Cut(line, "=") invalid := !found || !secretBundleKeyPattern.MatchString(key) || strings.TrimSpace(key) != key || secret == "" || strings.TrimSpace(secret) != secret || strings.Contains(strings.ToLower(secret), "replace-me") || strings.IndexFunc(secret, unicode.IsSpace) >= 0 if invalid { return fmt.Errorf("complete setup found an invalid secret bundle entry at line %d", lineNumber+1) } if _, duplicate := seen[key]; duplicate { return fmt.Errorf("complete setup found a duplicate secret bundle key %s", key) } seen[key] = struct{}{} } return nil } func inspectExistingSecretFile(path string) (bool, error) { before, err := os.Lstat(path) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, fmt.Errorf("secret file %s could not be inspected; choose a readable regular file", path) } if !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 { return false, fmt.Errorf("secret file %s must be a readable regular file, not a directory, symlink, or special file", path) } if _, err := safeio.ReadCanonicalRegular(path, maxSecretBytes); err != nil { return false, fmt.Errorf("secret file %s must be a canonical readable regular file", path) } return true, nil } func configuredSecretPaths(value answers) []string { paths := []string{value.secretsFile, value.piAuthFile} if value.workspaceAccess == "ssh" { paths = append(paths, value.gitSSHKeyFile, value.gitKnownHostsFile) } else { paths = append(paths, value.gitCredentialsFile, value.gitCAFile) } return paths } func secretTemplate(path string) []byte { if strings.HasSuffix(path, ".json") { return []byte("{}\n") } return []byte("# Add the required credential value to this protected local file.\n") } func writeNewFileAtomically(path string, contents []byte, mode os.FileMode) error { if err := safeio.ValidateCanonicalPath(path); err != nil { return err } directory := filepath.Dir(path) if info, err := os.Lstat(directory); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { return safeio.ErrUnsafeFile } resolvedDirectory, err := filepath.EvalSymlinks(directory) if err != nil || resolvedDirectory != directory { return safeio.ErrUnsafeFile } temporary, err := os.CreateTemp(directory, ".tht-setup-*") if err != nil { return err } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(mode); err != nil { temporary.Close() return err } if _, err := temporary.Write(contents); err != nil { temporary.Close() return err } if err := temporary.Sync(); err != nil { temporary.Close() return err } if err := temporary.Close(); err != nil { return err } if err := os.Link(temporaryPath, path); err != nil { return err } directoryFile, err := os.Open(directory) if err == nil { _ = directoryFile.Sync() _ = directoryFile.Close() } return nil } func accessForRemote(remote string) string { if strings.HasPrefix(remote, "git@") || strings.HasPrefix(remote, "ssh://") { return "ssh" } return "https" } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" }