From 4dd2213715748f7cf5431ff8b4e72817e4ce1738 Mon Sep 17 00:00:00 2001 From: John Beisley Date: Wed, 25 May 2022 18:27:18 +0100 Subject: [PATCH] Some initial experimentation with SCPD reading. --- v2alpha/cmd/goupnp2dcpgen/main.go | 124 ++++++++++++++ v2alpha/cmd/goupnp2dcpgen/zipread/zipread.go | 68 ++++++++ v2alpha/description/scpd/scpd.go | 167 +++++++++++++++++++ v2alpha/description/xmlscpd/xmlscpd.go | 161 ++++++++++++++++++ 4 files changed, 520 insertions(+) create mode 100644 v2alpha/cmd/goupnp2dcpgen/main.go create mode 100644 v2alpha/cmd/goupnp2dcpgen/zipread/zipread.go create mode 100644 v2alpha/description/scpd/scpd.go create mode 100644 v2alpha/description/xmlscpd/xmlscpd.go diff --git a/v2alpha/cmd/goupnp2dcpgen/main.go b/v2alpha/cmd/goupnp2dcpgen/main.go new file mode 100644 index 0000000..f377b79 --- /dev/null +++ b/v2alpha/cmd/goupnp2dcpgen/main.go @@ -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 +} diff --git a/v2alpha/cmd/goupnp2dcpgen/zipread/zipread.go b/v2alpha/cmd/goupnp2dcpgen/zipread/zipread.go new file mode 100644 index 0000000..16530a6 --- /dev/null +++ b/v2alpha/cmd/goupnp2dcpgen/zipread/zipread.go @@ -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) +} diff --git a/v2alpha/description/scpd/scpd.go b/v2alpha/description/scpd/scpd.go new file mode 100644 index 0000000..64f90af --- /dev/null +++ b/v2alpha/description/scpd/scpd.go @@ -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 +} diff --git a/v2alpha/description/xmlscpd/xmlscpd.go b/v2alpha/description/xmlscpd/xmlscpd.go new file mode 100644 index 0000000..7b768ec --- /dev/null +++ b/v2alpha/description/xmlscpd/xmlscpd.go @@ -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) + } + } +}