// Path: Backend/internal/posixproj/projector_scan.go package posixproj import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io/fs" "os" "path/filepath" "strings" "github.com/fxamacker/cbor/v2" "github.com/tailscale/hujson" ) const ( settingsCBORPath = "settings.cbor" ) func ScanRoot(root string) ([]Node, error) { rootPath := strings.TrimSpace(root) if rootPath == "" { return nil, nil } info, err := os.Stat(rootPath) if err != nil { return nil, fmt.Errorf("stat POSIX root: %w", err) } if !info.IsDir() { return nil, fmt.Errorf("POSIX root is not a directory: %s", rootPath) } rootScope, err := loadRootScope(rootPath) if err != nil { return nil, err } nodes := []Node{{ Path: rootProjectionPath, ParentPath: nil, Name: filepath.Base(rootPath), Depth: 0, NodeKind: NodeKindDirectory, LogicalType: "tenant_root", InstallationID: rootScope.InstallationID, OrganizationID: rootScope.OrganizationID, OrganizationSlug: rootScope.OrganizationSlug, }} err = filepath.WalkDir(rootPath, func(path string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if path == rootPath { return nil } relPath, err := filepath.Rel(rootPath, path) if err != nil { return err } relPath = filepath.ToSlash(relPath) if relPath == "." { return nil } node, err := buildNode(rootPath, relPath, entry, rootScope) if err != nil { return err } nodes = append(nodes, node) return nil }) if err != nil { return nil, fmt.Errorf("scan POSIX root: %w", err) } return nodes, nil } func loadRootScope(rootPath string) (Scope, error) { settingsPath := filepath.Join(rootPath, settingsCBORPath) content, err := os.ReadFile(settingsPath) if err != nil { if errorsIsNotExist(err) { return Scope{}, nil } return Scope{}, fmt.Errorf("read root %s: %w", filepath.Base(settingsPath), err) } payload, err := decodeStructuredMap(settingsPath, content) if err != nil { return Scope{}, fmt.Errorf("decode root %s: %w", filepath.Base(settingsPath), err) } if len(payload) == 0 { return Scope{}, nil } installation, _ := payload["installation"].(map[string]any) organization, _ := payload["organization"].(map[string]any) return Scope{ InstallationID: stringValue(installation["id"]), OrganizationID: stringValue(organization["id"]), OrganizationSlug: stringValue(organization["slug"]), }, nil } func buildNode(rootPath, relPath string, entry fs.DirEntry, rootScope Scope) (Node, error) { scope := deriveScope(relPath, rootScope) parentPath := projectionParentPath(relPath) logicalType, fileRole := classifyPath(relPath, entry.IsDir()) node := Node{ Path: relPath, ParentPath: parentPath, Name: entry.Name(), Depth: strings.Count(relPath, "/") + 1, NodeKind: NodeKindDirectory, LogicalType: logicalType, FileRole: fileRole, InstallationID: scope.InstallationID, OrganizationID: scope.OrganizationID, OrganizationSlug: scope.OrganizationSlug, DepartmentSlug: scope.DepartmentSlug, TeamSlug: scope.TeamSlug, ProjectSlug: scope.ProjectSlug, PersonalSlug: scope.PersonalSlug, } if entry.IsDir() { return node, nil } absPath := filepath.Join(rootPath, filepath.FromSlash(relPath)) content, err := os.ReadFile(absPath) if err != nil { return Node{}, fmt.Errorf("read POSIX file %s: %w", relPath, err) } hash := sha256.Sum256(content) node.NodeKind = NodeKindFile node.SizeBytes = int64(len(content)) node.Checksum = hex.EncodeToString(hash[:]) if isStructuredProjectionFile(entry.Name()) { payload, err := decodeStructuredMap(absPath, content) if err == nil { jsonContent, err := json.Marshal(payload) if err != nil { return Node{}, fmt.Errorf("remarshal POSIX file %s: %w", relPath, err) } node.ContentJSON = jsonContent node.ResourceID = stringValue(payload["id"]) node.ResourceName = stringValue(payload["name"]) node.ResourceSlug = stringValue(payload["slug"]) if node.ResourceID == "" && fileRole == "settings" && logicalType == "tenant" { installation, _ := payload["installation"].(map[string]any) organization, _ := payload["organization"].(map[string]any) node.ResourceID = stringValue(installation["id"]) node.ResourceName = stringValue(installation["name"]) node.InstallationID = stringValue(installation["id"]) node.OrganizationID = stringValue(organization["id"]) node.OrganizationSlug = firstNonEmpty(node.OrganizationSlug, stringValue(organization["slug"])) } if node.ResourceID == "" && fileRole == "users" { node.ResourceName = firstNonEmpty(node.ResourceName, parentEntityName(logicalType, scope)) } } } if node.ResourceSlug == "" { node.ResourceSlug = inferredResourceSlug(logicalType, scope) } return node, nil } func isStructuredProjectionFile(name string) bool { switch strings.ToLower(filepath.Ext(name)) { case ".json", ".jsonc", ".cbor": return true default: return false } } func decodeStructuredMap(path string, content []byte) (map[string]any, error) { var payload any switch strings.ToLower(filepath.Ext(path)) { case ".cbor": if err := cbor.Unmarshal(content, &payload); err != nil { return nil, err } case ".json": if err := json.Unmarshal(content, &payload); err != nil { return nil, err } case ".jsonc": decoded, err := decodeJSONCToAny(content) if err != nil { return nil, err } payload = decoded default: return nil, fmt.Errorf("unsupported structured file extension %q", filepath.Ext(path)) } if payload == nil { return map[string]any{}, nil } normalized, ok := normalizeStructuredValue(payload).(map[string]any) if !ok || normalized == nil { return map[string]any{}, nil } return normalized, nil } func decodeJSONCToAny(content []byte) (any, error) { ast, err := hujson.Parse(content) if err != nil { return nil, err } ast.Standardize() standardized := ast.Pack() var payload any if err := json.Unmarshal(standardized, &payload); err != nil { return nil, err } return payload, nil } func normalizeStructuredValue(value any) any { switch typed := value.(type) { case map[string]any: normalized := make(map[string]any, len(typed)) for key, child := range typed { normalized[key] = normalizeStructuredValue(child) } return normalized case map[any]any: normalized := make(map[string]any, len(typed)) for key, child := range typed { normalized[fmt.Sprint(key)] = normalizeStructuredValue(child) } return normalized case []any: normalized := make([]any, len(typed)) for index, child := range typed { normalized[index] = normalizeStructuredValue(child) } return normalized default: return value } } func summarizeNodes(nodes []Node) RebuildSummary { summary := RebuildSummary{TotalNodes: len(nodes)} for _, node := range nodes { switch node.NodeKind { case NodeKindDirectory: summary.DirectoryCount++ case NodeKindFile: summary.FileCount++ } } return summary } func errorsIsNotExist(err error) bool { return err != nil && os.IsNotExist(err) }