package admin import ( "bytes" "fmt" "path/filepath" "strings" "gopkg.in/yaml.v3" "thehouseoficarus/internal/persistence" "thehouseoficarus/internal/world" ) // validEntityID reports whether an entity ID can be safely joined into a // file path under its data directory. IDs come from URL path segments and // JSON bodies, so an unchecked join could otherwise escape the data // directory ("../") or touch dot files. func validEntityID(id string) bool { if id == "" || id == "." || id == ".." { return false } if strings.ContainsAny(id, `/\`) || strings.ContainsRune(id, 0) { return false } return true } // resolveRoomSubdir validates a client-supplied room subdirectory and returns // the cleaned path relative to /rooms. Absolute paths, ".." escapes, // drive-like prefixes, and backslashes are rejected; the empty string is the // rooms root. Room-directory API handlers must route body.Dir/patch.Dir through // this before joining, mirroring validEntityID/resolveDataPath elsewhere. func (s *AdminServer) resolveRoomSubdir(dir string) (string, error) { dir = strings.TrimSpace(dir) if dir == "" { return "", nil } if filepath.IsAbs(dir) || strings.ContainsRune(dir, '\\') || strings.ContainsRune(dir, 0) { return "", fmt.Errorf("invalid directory") } clean := filepath.Clean(dir) if clean == "." { return "", nil } if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { return "", fmt.Errorf("directory escapes the rooms tree") } base := filepath.Join(s.dataDir, "rooms") rel, err := filepath.Rel(base, filepath.Join(base, clean)) if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { return "", fmt.Errorf("directory escapes the rooms tree") } return clean, nil } // validateRoomSchema decodes a rendered room YAML into world.Room before it is // written, so the editor cannot persist a room the runtime loader would later // reject (for example a non-numeric exit target). KnownFields(true) also // catches keys the runtime would silently ignore. func validateRoomSchema(content []byte) error { dec := yaml.NewDecoder(bytes.NewReader(content)) dec.KnownFields(true) var room world.Room return dec.Decode(&room) } func yamlToMap(data []byte) (map[string]any, error) { var v any if err := yaml.Unmarshal(data, &v); err != nil { return nil, err } m, ok := stringifyMap(v).(map[string]any) if !ok { return nil, fmt.Errorf("yaml root is not a map") } return m, nil } func stringifyMap(v any) any { switch val := v.(type) { case map[interface{}]interface{}: out := make(map[string]any, len(val)) for k, v := range val { out[fmt.Sprint(k)] = stringifyMap(v) } return out case map[string]interface{}: out := make(map[string]any, len(val)) for k, v := range val { out[k] = stringifyMap(v) } return out case []interface{}: out := make([]any, len(val)) for i, e := range val { out[i] = stringifyMap(e) } return out default: return v } } func writeMapAsYAML(path string, m map[string]any) ([]byte, error) { content, err := marshalMapYAML(m) if err != nil { return nil, err } if err := persistence.WriteFile(path, content, 0644); err != nil { return nil, err } return content, nil } // marshalMapYAML serializes a generic map without writing: callers that // only need the after-image (mutation-plan preflight) use this. func marshalMapYAML(m map[string]any) ([]byte, error) { var buf bytes.Buffer enc := yaml.NewEncoder(&buf) enc.SetIndent(2) if err := enc.Encode(m); err != nil { return nil, err } if err := enc.Close(); err != nil { return nil, err } return buf.Bytes(), nil }