278 lines
7.1 KiB
Go
278 lines
7.1 KiB
Go
// 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)
|
|
}
|