// Package storage provides file-based persistence for HMI interface definitions. package storage import ( "encoding/xml" "errors" "fmt" "os" "path/filepath" "strings" "time" "unicode" ) // ErrNotFound is returned when the requested interface does not exist. var ErrNotFound = errors.New("interface not found") // InterfaceMeta is the lightweight representation returned by List. type InterfaceMeta struct { ID string `json:"id"` Name string `json:"name"` Version int `json:"version"` } // Store persists interface definitions as XML files under {storageDir}/interfaces/. type Store struct { dir string } // New opens (and creates, if needed) the storage directory. func New(storageDir string) (*Store, error) { dir := filepath.Join(storageDir, "interfaces") if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create interfaces dir: %w", err) } return &Store{dir: dir}, nil } // validateID returns an error if id could be used for path traversal or is // otherwise unsafe. Valid IDs contain only letters, digits, hyphens, and // underscores. func validateID(id string) error { if id == "" { return fmt.Errorf("interface ID must not be empty") } for _, r := range id { if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' { return fmt.Errorf("invalid character %q in interface ID", r) } } return nil } func (s *Store) filePath(id string) string { return filepath.Join(s.dir, id+".xml") } // List returns metadata for every stored interface. func (s *Store) List() ([]InterfaceMeta, error) { entries, err := os.ReadDir(s.dir) if err != nil { return nil, err } var out []InterfaceMeta for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".xml") { continue } id := strings.TrimSuffix(e.Name(), ".xml") meta, err := s.readMeta(id) if err != nil { continue // skip corrupt files silently } out = append(out, meta) } if out == nil { out = []InterfaceMeta{} } return out, nil } // rootAttrs is used to parse only the top-level XML attributes. type rootAttrs struct { XMLName xml.Name `xml:"interface"` ID string `xml:"id,attr"` Name string `xml:"name,attr"` Version int `xml:"version,attr"` } func (s *Store) readMeta(id string) (InterfaceMeta, error) { data, err := os.ReadFile(s.filePath(id)) if err != nil { return InterfaceMeta{}, err } var root rootAttrs if err := xml.Unmarshal(data, &root); err != nil { return InterfaceMeta{}, err } return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil } // Get returns the raw XML bytes for the interface with the given ID. func (s *Store) Get(id string) ([]byte, error) { if err := validateID(id); err != nil { return nil, fmt.Errorf("%w: %v", ErrNotFound, err) } data, err := os.ReadFile(s.filePath(id)) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } return data, err } // Create stores new interface XML and returns the generated ID. // The ID is derived from the name attribute; a timestamp suffix is added to // ensure uniqueness. func (s *Store) Create(xmlData []byte) (string, error) { var root rootAttrs if err := xml.Unmarshal(xmlData, &root); err != nil { return "", fmt.Errorf("invalid XML: %w", err) } id := root.ID if id == "" { id = slugify(root.Name) } // Guarantee uniqueness. if _, err := os.Stat(s.filePath(id)); err == nil { id = id + "-" + fmt.Sprintf("%d", time.Now().UnixMilli()) } return id, os.WriteFile(s.filePath(id), xmlData, 0o644) } // Update replaces the XML for an existing interface. func (s *Store) Update(id string, xmlData []byte) error { if err := validateID(id); err != nil { return ErrNotFound } if _, err := os.Stat(s.filePath(id)); errors.Is(err, os.ErrNotExist) { return ErrNotFound } return os.WriteFile(s.filePath(id), xmlData, 0o644) } // Delete removes the interface with the given ID. func (s *Store) Delete(id string) error { if err := validateID(id); err != nil { return ErrNotFound } err := os.Remove(s.filePath(id)) if errors.Is(err, os.ErrNotExist) { return ErrNotFound } return err } // Clone duplicates an interface, assigning a new unique ID. func (s *Store) Clone(srcID string) (string, error) { if err := validateID(srcID); err != nil { return "", ErrNotFound } data, err := s.Get(srcID) if err != nil { return "", err } newID := srcID + "-copy-" + fmt.Sprintf("%d", time.Now().UnixMilli()) return newID, os.WriteFile(s.filePath(newID), data, 0o644) } // slugify converts a human-readable name into a URL-safe identifier. func slugify(s string) string { var b strings.Builder for _, r := range strings.ToLower(s) { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9': b.WriteRune(r) default: if b.Len() > 0 && b.String()[b.Len()-1] != '-' { b.WriteByte('-') } } } result := strings.Trim(b.String(), "-") if result == "" { return "interface" } return result }