Some initial experimentation with SCPD reading.

This commit is contained in:
John Beisley 2022-05-25 18:27:18 +01:00
parent cc75a26e13
commit 4dd2213715
4 changed files with 520 additions and 0 deletions

View File

@ -0,0 +1,124 @@
package main
import (
"encoding/xml"
"errors"
"flag"
"fmt"
"os"
"strings"
"github.com/huin/goupnp/v2alpha/cmd/goupnp2dcpgen/zipread"
"github.com/huin/goupnp/v2alpha/description/scpd"
"github.com/huin/goupnp/v2alpha/description/xmlscpd"
)
var (
upnpresourcesZip = flag.String("upnpresources_zip", "", "Path to upnpresources.zip.")
)
func main() {
flag.Parse()
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func run() error {
if len(flag.Args()) > 0 {
return fmt.Errorf("unused arguments: %s", strings.Join(flag.Args(), " "))
}
if *upnpresourcesZip == "" {
return errors.New("-upnpresources_zip is a required flag.")
}
f, err := os.Open(*upnpresourcesZip)
if err != nil {
return err
}
defer f.Close()
upnpresources, err := zipread.FromOsFile(f)
if err != nil {
return err
}
for _, m := range manifests {
if err := processDCP(upnpresources, m); err != nil {
return fmt.Errorf("processing DCP %s: %w", m.Path, err)
}
}
return nil
}
var manifests = []*DCPSpecManifest{
{
Path: "standardizeddcps/Internet Gateway_2/UPnP-gw-IGD-TestFiles-20101210.zip",
Services: map[string]string{
"LANHostConfigManagement:1": "xml data files/service/LANHostConfigManagement1.xml",
"WANPPPConnection:1": "xml data files/service/WANPPPConnection1.xml",
},
},
}
func processDCP(
upnpresources *zipread.ZipRead,
manifest *DCPSpecManifest,
) error {
dcpSpecData, err := upnpresources.OpenZip(manifest.Path)
if err != nil {
return err
}
for name, path := range manifest.Services {
if err := processService(dcpSpecData, name, path); err != nil {
return fmt.Errorf("processing service %s: %w", name, err)
}
}
return nil
}
func processService(
dcpSpecData *zipread.ZipRead,
name string,
path string,
) error {
fmt.Printf("%s\n", name)
f, err := dcpSpecData.Open(path)
if err != nil {
return err
}
defer f.Close()
d := xml.NewDecoder(f)
xmlSCPD := &xmlscpd.SCPD{}
if err := d.Decode(xmlSCPD); err != nil {
return err
}
xmlSCPD.Clean()
for _, action := range xmlSCPD.Actions {
fmt.Printf("* %s()\n", action.Name)
for _, arg := range action.Arguments {
direction := "?"
if arg.Direction == "in" {
direction = "<-"
} else if arg.Direction == "out" {
direction = "->"
}
fmt.Printf(" %s %s %s\n", direction, arg.Name, arg.RelatedStateVariable)
}
}
_, err := scpd.FromXML(xmlSCPD)
if err != nil {
return err
}
return nil
}
type DCPSpecManifest struct {
// Path is the file path within upnpresources.zip to the DCP spec ZIP file.
Path string
// Services maps from a service name (e.g. "FooBar:1") to a path within the DCP spec ZIP file
// (e.g. "xml data files/service/FooBar1.xml").
Services map[string]string
}

View File

@ -0,0 +1,68 @@
package zipread
import (
"archive/zip"
"bytes"
"io"
"io/fs"
"os"
)
type SizedReaderAt struct {
R io.ReaderAt
Size int64
}
func NewSizedReaderFromOsFile(f *os.File) (*SizedReaderAt, error) {
stat, err := f.Stat()
if err != nil {
return nil, err
}
return &SizedReaderAt{R: f, Size: stat.Size()}, nil
}
func NewSizedReaderFromReader(r io.Reader) (*SizedReaderAt, error) {
data, err := io.ReadAll(r)
if err != nil {
return nil, err
}
contents := bytes.NewReader(data)
return &SizedReaderAt{R: contents, Size: int64(len(data))}, nil
}
type ZipRead struct {
*zip.Reader
}
func New(r *SizedReaderAt) (*ZipRead, error) {
zr, err := zip.NewReader(r.R, r.Size)
if err != nil {
return nil, err
}
return &ZipRead{zr}, nil
}
func FromOsFile(f *os.File) (*ZipRead, error) {
r, err := NewSizedReaderFromOsFile(f)
if err != nil {
return nil, err
}
return New(r)
}
func FromFsFile(f fs.File) (*ZipRead, error) {
r, err := NewSizedReaderFromReader(f)
if err != nil {
return nil, err
}
return New(r)
}
func (zr *ZipRead) OpenZip(path string) (*ZipRead, error) {
f, err := zr.Reader.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return FromFsFile(f)
}

View File

@ -0,0 +1,167 @@
// Package scpd contains data structures that represent an SCPD at a higher level than XML.
package scpd
import (
"errors"
"fmt"
"sort"
"github.com/huin/goupnp/v2alpha/description/xmlscpd"
)
var (
BadDescriptionError = errors.New("bad XML description")
UnsupportedDescriptionError = errors.New("unsupported XML description")
)
// SCPD is the top level service description.
type SCPD struct {
actionByName map[string]*Action
variableByName map[string]*StateVariable
}
// FromXML creates an SCPD from XML data.
//
// It assumes that xmlDesc.Clean() has been called.
func FromXML(xmlDesc *xmlscpd.SCPD) (*SCPD, error) {
stateVariables := make(map[string]*StateVariable, len(xmlDesc.StateVariables))
for _, xmlSV := range xmlDesc.StateVariables {
sv, err := stateVariableFromXML(xmlSV)
if err != nil {
return nil, fmt.Errorf("processing state variable %q: %w", xmlSV.Name, err)
}
if _, exists := stateVariables[sv.name]; exists {
return nil, fmt.Errorf("%w: multiple state variables with name %q",
BadDescriptionError, sv.name)
}
stateVariables[sv.name] = sv
}
actions := make(map[string]*Action, len(xmlDesc.Actions))
for _, xmlAction := range xmlDesc.Actions {
action, err := actionFromXML(xmlAction)
if err != nil {
return nil, fmt.Errorf("processing action %q: %w", xmlAction.Name, err)
}
if _, exists := actions[action.name]; exists {
return nil, fmt.Errorf("%w: multiple actions with name %q",
BadDescriptionError, action.name)
}
actions[action.name] = action
}
return &SCPD{
actionByName: actions,
variableByName: stateVariables,
}, nil
}
// ActionNames returns the ordered names of each action.
func (scpd *SCPD) ActionNames() []string {
names := make([]string, 0, len(scpd.actionByName))
for name := range scpd.actionByName {
names = append(names, name)
}
sort.Strings(names)
return names
}
// Action returns an action with the given name.
func (scpd *SCPD) Action(name string) *Action {
return scpd.actionByName[name]
}
// Variable returns a state variable with the given name.
func (scpd *SCPD) Variable(name string) *StateVariable {
return scpd.variableByName[name]
}
// Action describes a single UPnP SOAP action.
type Action struct {
name string
inArgs []*Argument
outArgs []*Argument
}
// actionFromXML creates an Action from the given XML description.
func actionFromXML(xmlAction *xmlscpd.Action) (*Action, error) {
if xmlAction.Name == "" {
return nil, fmt.Errorf("%w: empty action name", BadDescriptionError)
}
var inArgs []*Argument
var outArgs []*Argument
for _, xmlArg := range xmlAction.Arguments {
arg, err := argumentFromXML(xmlArg)
if err != nil {
return nil, fmt.Errorf("processing argument %q: %w", xmlArg.Name, err)
}
switch xmlArg.Direction {
case "in":
inArgs = append(inArgs, arg)
case "out":
outArgs = append(outArgs, arg)
default:
return nil, fmt.Errorf("%w: argument %q has invalid direction %q",
BadDescriptionError, xmlArg.Name, xmlArg.Direction)
}
}
return &Action{
name: xmlAction.Name,
inArgs: inArgs,
outArgs: outArgs,
}, nil
}
// Argument description data.
type Argument struct {
name string
relatedStateVariable string
}
// argumentFromXML creates an Argument from the XML description.
func argumentFromXML(xmlArg *xmlscpd.Argument) (*Argument, error) {
if xmlArg.Name == "" {
return nil, fmt.Errorf("%w: empty argument name", BadDescriptionError)
}
if xmlArg.RelatedStateVariable == "" {
return nil, fmt.Errorf("%w: empty related state variable", BadDescriptionError)
}
return &Argument{
name: xmlArg.Name,
relatedStateVariable: xmlArg.RelatedStateVariable,
}, nil
}
func (arg *Argument) Name() string {
return arg.name
}
func (arg *Argument) RelatedStateVariableName() string {
return arg.relatedStateVariable
}
// StateVariable description data.
type StateVariable struct {
name string
dataType string
}
func stateVariableFromXML(xmlSV *xmlscpd.StateVariable) (*StateVariable, error) {
if xmlSV.Name == "" {
return nil, fmt.Errorf("%w: empty state variable name", BadDescriptionError)
}
if xmlSV.DataType.Type != "" {
return nil, fmt.Errorf("%w: unsupported data type %q",
UnsupportedDescriptionError, xmlSV.DataType.Type)
}
return &StateVariable{
name: xmlSV.Name,
dataType: xmlSV.DataType.Name,
}, nil
}
func (sv *StateVariable) Name() string {
return sv.name
}
func (sv *StateVariable) DataTypeName() string {
return sv.dataType
}

View File

@ -0,0 +1,161 @@
// Package xmlscpd contains the XML data structures used in SCPD (Service Control Protocol Description).
//
// Described in section 2.5 of
// https://openconnectivity.org/upnp-specs/UPnP-arch-DeviceArchitecture-v2.0-20200417.pdf.
package xmlscpd
import (
"bytes"
"encoding/xml"
"fmt"
"strings"
)
func cleanWhitespace(s *string) {
*s = strings.TrimSpace(*s)
}
// SCPD is the top level XML service description.
type SCPD struct {
XMLName xml.Name `xml:"scpd"`
ConfigId string `xml:"configId,attr"`
SpecVersion SpecVersion `xml:"specVersion"`
Actions []*Action `xml:"actionList>action"`
StateVariables []*StateVariable `xml:"serviceStateTable>stateVariable"`
}
// Clean removes stray whitespace in the structure.
//
// It's common for stray whitespace to be present in SCPD documents, this method removes them
// in-place.
func (scpd *SCPD) Clean() {
cleanWhitespace(&scpd.ConfigId)
for i := range scpd.Actions {
scpd.Actions[i].Clean()
}
for i := range scpd.StateVariables {
scpd.StateVariables[i].Clean()
}
}
// SpecVersion is part of a SCPD document, describes the version of the
// specification that the data adheres to.
type SpecVersion struct {
Major int32 `xml:"major"`
Minor int32 `xml:"minor"`
}
// Action XML description data.
type Action struct {
Name string `xml:"name"`
Arguments []*Argument `xml:"argumentList>argument"`
}
// Clean removes stray whitespace in the structure.
func (action *Action) Clean() {
cleanWhitespace(&action.Name)
for i := range action.Arguments {
action.Arguments[i].Clean()
}
}
// Argument XML data.
type Argument struct {
Name string `xml:"name"`
Direction string `xml:"direction"` // in|out
RelatedStateVariable string `xml:"relatedStateVariable"` // ?
Retval string `xml:"retval"` // ?
}
// Clean removes stray whitespace in the structure.
func (arg *Argument) Clean() {
cleanWhitespace(&arg.Name)
cleanWhitespace(&arg.Direction)
cleanWhitespace(&arg.RelatedStateVariable)
cleanWhitespace(&arg.Retval)
}
// StateVariable XML data.
type StateVariable struct {
Optional PresenceBool
Name string `xml:"name"`
SendEvents string `xml:"sendEvents,attr"` // yes|no
Multicast string `xml:"multicast,attr"` // yes|no
DataType DataType `xml:"dataType"`
DefaultValue string `xml:"defaultValue"`
AllowedValueRange *AllowedValueRange `xml:"allowedValueRange"`
AllowedValues []string `xml:"allowedValueList>allowedValue"`
}
// Clean removes stray whitespace in the structure.
func (v *StateVariable) Clean() {
cleanWhitespace(&v.Name)
cleanWhitespace(&v.SendEvents)
cleanWhitespace(&v.Multicast)
v.DataType.Clean()
cleanWhitespace(&v.DefaultValue)
if v.AllowedValueRange != nil {
v.AllowedValueRange.Clean()
}
for i := range v.AllowedValues {
cleanWhitespace(&v.AllowedValues[i])
}
}
// AllowedValueRange XML data.
type AllowedValueRange struct {
Minimum string `xml:"minimum"`
Maximum string `xml:"maximum"`
Step string `xml:"step"`
}
// Clean removes stray whitespace in the structure.
func (r *AllowedValueRange) Clean() {
cleanWhitespace(&r.Minimum)
cleanWhitespace(&r.Maximum)
cleanWhitespace(&r.Step)
}
// DataType XML data.
type DataType struct {
Name string `xml:",chardata"`
Type string `xml:"type,attr"`
}
// Clean removes stray whitespace in the structure.
func (dt *DataType) Clean() {
cleanWhitespace(&dt.Name)
cleanWhitespace(&dt.Type)
}
// PresenceBool represents an empty XML element that is true if present.
//
// Is an error if it contains any attributes or contents.
type PresenceBool bool
func (pb *PresenceBool) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
*pb = true
if len(start.Attr) > 0 {
return fmt.Errorf("unexpected attributes on element %s:%s",
start.Name.Space, start.Name.Local)
}
for {
tok, err := d.Token()
if err != nil {
return err
}
switch tok := tok.(type) {
case xml.CharData:
if len(bytes.TrimSpace([]byte(tok))) > 0 {
return fmt.Errorf("unexpected char data on element %s:%s",
start.Name.Space, start.Name.Local)
}
case xml.EndElement:
return nil
case xml.Comment:
default:
return fmt.Errorf("unexpected %T token on element %s:%s",
tok, start.Name.Space, start.Name.Local)
}
}
}