/* concourse-debian-dsa a Concourse CI resource type to get Debian security update information Copyright Jan Dittberner This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . */ package dsa import ( "encoding/json" "encoding/xml" "errors" "fmt" "io" "log" "net/http" "sort" "strings" "time" ) const rdfURL = "https://www.debian.org/security/dsa" const rdfLongURL = "https://www.debian.org/security/dsa-long" const isoDateFormat = "2006-01-02" var ErrNotSupported = errors.New("unsupported operation") func parseISODate(dateStr string) (time.Time, error) { date, err := time.Parse(isoDateFormat, dateStr) if err != nil { return time.Time{}, fmt.Errorf("could not parse date: %w", err) } return date, nil } func getRdfData(full bool) (*rdfData, error) { client := &http.Client{} var url string if full { url = rdfLongURL } else { url = rdfURL } resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("could not fetch Debian Security Announcement RDF: %w", err) } defer func() { _ = resp.Body.Close() }() rdfBytes, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("could not read RDF data from response: %w", err) } data := &rdfData{} err = xml.Unmarshal(rdfBytes, data) if err != nil { return nil, fmt.Errorf("could not parse RDF data: %w", err) } return data, nil } type Version struct { Date string `json:"date"` DSANumber string `json:"dsa"` } func (v Version) String() string { data, _ := json.Marshal(v) return string(data) } type checkInput struct { Source any `json:"source"` Version Version `json:"version"` } type getInput struct { Source any `json:"source"` Version Version `json:"version"` Params any `json:"params"` } type MetaData struct { Link string `json:"link"` Package string `json:"package"` Description string `json:"description"` } type getOutput struct { Version Version `json:"version"` Metadata MetaData `json:"metadata"` } type Resource struct { in io.Reader out io.Writer } type VersionRange struct { versions []Version } func (r *VersionRange) Len() int { return len(r.versions) } func (r *VersionRange) Less(i, j int) bool { return r.versions[i].Date <= r.versions[j].Date && r.versions[i].DSANumber < r.versions[j].DSANumber } func (r *VersionRange) Swap(i, j int) { tmpVer := r.versions[i] r.versions[i] = r.versions[j] r.versions[j] = tmpVer } func (r *VersionRange) addVersion(version Version) { r.versions = append(r.versions, version) } func (r *Resource) Check() error { input, err := r.readCheckInput() if err != nil { return fmt.Errorf("could not parse input from Concourse: %w", err) } var inputDate time.Time if len(input.Version.Date) > 0 { inputDate, err = parseISODate(input.Version.Date) if err != nil { return fmt.Errorf("could not interpret version from Concourse as date: %w", err) } } dsaData, err := getRdfData(false) if err != nil { log.Fatalf("could not get DSA RDF data: %v", err) } dates := &VersionRange{} for _, item := range dsaData.Items { parts := strings.SplitN(item.Title, " ", 2) versionDate, err := parseISODate(item.Date) if err != nil { return fmt.Errorf("could not interpret date from RDF feed as date: %w", err) } if inputDate.IsZero() || inputDate.Before(versionDate) { dates.addVersion(Version{Date: item.Date, DSANumber: parts[0]}) } } sort.Sort(dates) err = r.writeCheckOutput(dates) if err != nil { return fmt.Errorf("could not write output for Concourse: %w", err) } return nil } func (r *Resource) Get(_ string) error { input, err := r.readGetInput() if err != nil { return fmt.Errorf("could not parse input from Concourse: %w", err) } _, err = parseISODate(input.Version.Date) if err != nil { return fmt.Errorf("could not interpret version from Concourse as date: %w", err) } dsaData, err := getRdfData(true) if err != nil { log.Fatalf("could not get DSA RDF data: %v", err) } for _, item := range dsaData.Items { if item.Date == input.Version.Date && strings.HasPrefix(item.Title, input.Version.DSANumber) { parts := strings.SplitN(item.Title, " ", 3) err = r.writeGetOutput(Version{ Date: item.Date, DSANumber: parts[0], }, MetaData{ Link: item.Link, Package: parts[1], Description: strings.TrimSpace( strings.ReplaceAll(strings.ReplaceAll( item.Description, "

", ""), "

", "\n", ), ), }) if err != nil { return fmt.Errorf("could not write output for Concourse: %w", err) } return nil } } return fmt.Errorf("requested version %s not found", input.Version) } func (r *Resource) readCheckInput() (*checkInput, error) { dec := json.NewDecoder(r.in) inp := &checkInput{} err := dec.Decode(inp) if err != nil { return nil, fmt.Errorf("could not decode Concourse input: %w", err) } return inp, nil } func (r *Resource) writeCheckOutput(versions *VersionRange) error { enc := json.NewEncoder(r.out) err := enc.Encode(versions.versions) if err != nil { return fmt.Errorf("could not marshal JSON: %w", err) } return nil } func (r *Resource) readGetInput() (*getInput, error) { dec := json.NewDecoder(r.in) inp := &getInput{} err := dec.Decode(inp) if err != nil { return nil, fmt.Errorf("could not decode Concourse input: %w", err) } return inp, nil } func (r *Resource) writeGetOutput(version Version, metaData MetaData) error { enc := json.NewEncoder(r.out) output := &getOutput{ Version: version, Metadata: metaData, } err := enc.Encode(&output) if err != nil { return fmt.Errorf("could not marshal JSON: %w", err) } return nil } func NewResource(in io.Reader, out io.Writer) *Resource { return &Resource{in: in, out: out} }