From 0e297fc61633f859f5eefcecc380589352eb9a29 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 02:22:45 +0200 Subject: [PATCH 01/12] Add internal model for time ranges. --- internal/models/models.go | 81 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 internal/models/models.go diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 00000000..af31c80c --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,81 @@ +// This file is Free Software under the MIT License +// without warranty, see README.md and LICENSES/MIT.txt for details. +// +// SPDX-License-Identifier: MIT +// +// SPDX-FileCopyrightText: 2023 German Federal Office for Information Security (BSI) +// Software-Engineering: 2023 Intevation GmbH + +// Package models contains helper models used in the tools internally. +package models + +import ( + "fmt" + "strings" + "time" +) + +// TimeRange is a time interval. +type TimeRange [2]time.Time + +// NewTimeInterval creates a new time range. +// The time values will be sorted. +func NewTimeInterval(a, b time.Time) TimeRange { + if b.Before(a) { + a, b = b, a + } + return TimeRange{a, b} +} + +// guessDate tries to guess an RFC 3339 date time from a given string. +func guessDate(s string) (time.Time, bool) { + for _, layout := range []string{ + "2006-01-02T15:04:05Z07:00", + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02T15", + "2006-01-02", + "2006-01", + "2006", + } { + if t, err := time.Parse(s, layout); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// UnmarshalText implements [encoding/text.TextUnmarshaler]. +func (tr *TimeRange) UnmarshalText(text []byte) error { + + s := strings.TrimSpace(string(text)) + // Handle relative case first. + if duration, err := time.ParseDuration(s); err == nil { + now := time.Now() + *tr = NewTimeInterval(now.Add(-duration), now) + return nil + } + + a, b, found := strings.Cut(s, ",") + a, b = strings.TrimSpace(a), strings.TrimSpace(b) + + // Only start date? + if !found { + start, ok := guessDate(a) + if !ok { + return fmt.Errorf("%q is not a valid RFC date time", a) + } + *tr = NewTimeInterval(start, time.Now()) + } + // Real interval + start, ok := guessDate(a) + if !ok { + return fmt.Errorf("%q is not a valid RFC date time", a) + } + end, ok := guessDate(b) + if !ok { + return fmt.Errorf("%q is not a valid RFC date time", b) + } + *tr = NewTimeInterval(start, end) + return nil +} From de0599ebe364762afcdf9d81691d0f313d6ad32c Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 03:22:33 +0200 Subject: [PATCH 02/12] Add time interval filtering to downloader. --- cmd/csaf_downloader/config.go | 19 ++++++++++++------- cmd/csaf_downloader/downloader.go | 5 +++++ csaf/advisories.go | 19 ++++++++++++++----- internal/models/models.go | 5 +++++ 4 files changed, 36 insertions(+), 12 deletions(-) diff --git a/cmd/csaf_downloader/config.go b/cmd/csaf_downloader/config.go index 45afcdc8..cba4be42 100644 --- a/cmd/csaf_downloader/config.go +++ b/cmd/csaf_downloader/config.go @@ -10,7 +10,9 @@ package main import ( "net/http" + "time" + "github.com/csaf-poc/csaf_distribution/v2/internal/models" "github.com/csaf-poc/csaf_distribution/v2/internal/options" ) @@ -20,13 +22,14 @@ const ( ) type config struct { - Directory *string `short:"d" long:"directory" description:"DIRectory to store the downloaded files in" value-name:"DIR" toml:"directory"` - Insecure bool `long:"insecure" description:"Do not check TLS certificates from provider" toml:"insecure"` - IgnoreSignatureCheck bool `long:"ignoresigcheck" description:"Ignore signature check results, just warn on mismatch" toml:"ignoresigcheck"` - Version bool `long:"version" description:"Display version of the binary" toml:"-"` - Verbose bool `long:"verbose" short:"v" description:"Verbose output" toml:"verbose"` - Rate *float64 `long:"rate" short:"r" description:"The average upper limit of https operations per second (defaults to unlimited)" toml:"rate"` - Worker int `long:"worker" short:"w" description:"NUMber of concurrent downloads" value-name:"NUM" toml:"worker"` + Directory *string `short:"d" long:"directory" description:"DIRectory to store the downloaded files in" value-name:"DIR" toml:"directory"` + Insecure bool `long:"insecure" description:"Do not check TLS certificates from provider" toml:"insecure"` + IgnoreSignatureCheck bool `long:"ignoresigcheck" description:"Ignore signature check results, just warn on mismatch" toml:"ignoresigcheck"` + Version bool `long:"version" description:"Display version of the binary" toml:"-"` + Verbose bool `long:"verbose" short:"v" description:"Verbose output" toml:"verbose"` + Rate *float64 `long:"rate" short:"r" description:"The average upper limit of https operations per second (defaults to unlimited)" toml:"rate"` + Worker int `long:"worker" short:"w" description:"NUMber of concurrent downloads" value-name:"NUM" toml:"worker"` + Range *models.TimeRange `long:"timerange" short:"t" description:"RANGE of time from which advisories to download" value-name:"RANGE" toml:"timerange"` ExtraHeader http.Header `long:"header" short:"H" description:"One or more extra HTTP header fields" toml:"header"` @@ -35,6 +38,8 @@ type config struct { RemoteValidatorPresets []string `long:"validatorpreset" description:"One or more PRESETS to validate remotely" value-name:"PRESETS" toml:"validatorpreset"` Config string `short:"c" long:"config" description:"Path to config TOML file" value-name:"TOML-FILE" toml:"-"` + + ageAccept func(time.Time) bool } // configPaths are the potential file locations of the config file. diff --git a/cmd/csaf_downloader/downloader.go b/cmd/csaf_downloader/downloader.go index 41bd0768..690aa781 100644 --- a/cmd/csaf_downloader/downloader.go +++ b/cmd/csaf_downloader/downloader.go @@ -153,6 +153,11 @@ func (d *downloader) download(ctx context.Context, domain string) error { base, nil) + // Do we need time range based filtering? + if d.cfg.Range != nil { + afp.AgeAccept = d.cfg.Range.Contains + } + return afp.Process(func(label csaf.TLPLabel, files []csaf.AdvisoryFile) error { return d.downloadFiles(ctx, label, files) }) diff --git a/csaf/advisories.go b/csaf/advisories.go index a371b659..1d404a84 100644 --- a/csaf/advisories.go +++ b/csaf/advisories.go @@ -14,6 +14,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/csaf-poc/csaf_distribution/v2/util" ) @@ -71,11 +72,12 @@ func (haf HashedAdvisoryFile) SignURL() string { return haf.name(3, ".asc") } // AdvisoryFileProcessor implements the extraction of // advisory file names from a given provider metadata. type AdvisoryFileProcessor struct { - client util.Client - expr *util.PathEval - doc any - base *url.URL - log func(format string, args ...any) + AgeAccept func(time.Time) bool + client util.Client + expr *util.PathEval + doc any + base *url.URL + log func(format string, args ...any) } // NewAdvisoryFileProcessor constructs an filename extractor @@ -287,6 +289,13 @@ func (afp *AdvisoryFileProcessor) processROLIE( rfeed.Entries(func(entry *Entry) { + // Filter if we have date checking. + if afp.AgeAccept != nil { + if pub := time.Time(entry.Published); !pub.IsZero() && !afp.AgeAccept(pub) { + return + } + } + var self, sha256, sha512, sign string for i := range entry.Link { diff --git a/internal/models/models.go b/internal/models/models.go index af31c80c..37e1daae 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -79,3 +79,8 @@ func (tr *TimeRange) UnmarshalText(text []byte) error { *tr = NewTimeInterval(start, end) return nil } + +// Contains return true if the given time is inside this time interval. +func (tr TimeRange) Contains(t time.Time) bool { + return !(t.Before(tr[0]) || t.After(tr[1])) +} From f8c3741d128f03925e76de841906b7022e7bf346 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 03:29:13 +0200 Subject: [PATCH 03/12] Remove stray field in config. --- cmd/csaf_downloader/config.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/csaf_downloader/config.go b/cmd/csaf_downloader/config.go index cba4be42..07e9de64 100644 --- a/cmd/csaf_downloader/config.go +++ b/cmd/csaf_downloader/config.go @@ -10,7 +10,6 @@ package main import ( "net/http" - "time" "github.com/csaf-poc/csaf_distribution/v2/internal/models" "github.com/csaf-poc/csaf_distribution/v2/internal/options" @@ -38,8 +37,6 @@ type config struct { RemoteValidatorPresets []string `long:"validatorpreset" description:"One or more PRESETS to validate remotely" value-name:"PRESETS" toml:"validatorpreset"` Config string `short:"c" long:"config" description:"Path to config TOML file" value-name:"TOML-FILE" toml:"-"` - - ageAccept func(time.Time) bool } // configPaths are the potential file locations of the config file. From 0ad4ed9e3686e41f7d8f403578b09d45ed564a76 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 03:31:00 +0200 Subject: [PATCH 04/12] Expose logging as field in AdvisoryFileProcessor to shrink constructor signature. --- cmd/csaf_aggregator/mirror.go | 3 +-- cmd/csaf_downloader/downloader.go | 3 +-- csaf/advisories.go | 6 ++---- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/cmd/csaf_aggregator/mirror.go b/cmd/csaf_aggregator/mirror.go index 1b197c42..0fd1de02 100644 --- a/cmd/csaf_aggregator/mirror.go +++ b/cmd/csaf_aggregator/mirror.go @@ -76,8 +76,7 @@ func (w *worker) mirrorInternal() (*csaf.AggregatorCSAFProvider, error) { w.client, w.expr, w.metadataProvider, - base, - nil) + base) if err := afp.Process(w.mirrorFiles); err != nil { return nil, err diff --git a/cmd/csaf_downloader/downloader.go b/cmd/csaf_downloader/downloader.go index 690aa781..cabe2fe3 100644 --- a/cmd/csaf_downloader/downloader.go +++ b/cmd/csaf_downloader/downloader.go @@ -150,8 +150,7 @@ func (d *downloader) download(ctx context.Context, domain string) error { client, d.eval, lpmd.Document, - base, - nil) + base) // Do we need time range based filtering? if d.cfg.Range != nil { diff --git a/csaf/advisories.go b/csaf/advisories.go index 1d404a84..5286d236 100644 --- a/csaf/advisories.go +++ b/csaf/advisories.go @@ -73,11 +73,11 @@ func (haf HashedAdvisoryFile) SignURL() string { return haf.name(3, ".asc") } // advisory file names from a given provider metadata. type AdvisoryFileProcessor struct { AgeAccept func(time.Time) bool + Log func(format string, args ...any) client util.Client expr *util.PathEval doc any base *url.URL - log func(format string, args ...any) } // NewAdvisoryFileProcessor constructs an filename extractor @@ -87,14 +87,12 @@ func NewAdvisoryFileProcessor( expr *util.PathEval, doc any, base *url.URL, - log func(format string, args ...any), ) *AdvisoryFileProcessor { return &AdvisoryFileProcessor{ client: client, expr: expr, doc: doc, base: base, - log: log, } } @@ -113,7 +111,7 @@ func empty(arr []string) bool { func (afp *AdvisoryFileProcessor) Process( fn func(TLPLabel, []AdvisoryFile) error, ) error { - lg := afp.log + lg := afp.Log if lg == nil { lg = func(format string, args ...any) { log.Printf("AdvisoryFileProcessor.Process: "+format, args...) From 204ddb5a965d48709b53945acc8dd46312a7fb97 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 03:56:05 +0200 Subject: [PATCH 05/12] Use changes.csv instead of index.txt when using dir bases provider to make date filtering possible. --- csaf/advisories.go | 63 +++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/csaf/advisories.go b/csaf/advisories.go index 5286d236..26ff8509 100644 --- a/csaf/advisories.go +++ b/csaf/advisories.go @@ -9,7 +9,9 @@ package csaf import ( - "bufio" + "encoding/csv" + "fmt" + "io" "log" "net/http" "net/url" @@ -173,7 +175,8 @@ func (afp *AdvisoryFileProcessor) Process( continue } - files, err := afp.loadIndex(base, lg) + // Use changes.csv to be able to filter by age. + files, err := afp.loadChanges(base, lg) if err != nil { return err } @@ -186,9 +189,9 @@ func (afp *AdvisoryFileProcessor) Process( return nil } -// loadIndex loads baseURL/index.txt and returns a list of files +// loadChanges loads baseURL/changes.csv and returns a list of files // prefixed by baseURL/. -func (afp *AdvisoryFileProcessor) loadIndex( +func (afp *AdvisoryFileProcessor) loadChanges( baseURL string, lg func(string, ...any), ) ([]AdvisoryFile, error) { @@ -197,29 +200,53 @@ func (afp *AdvisoryFileProcessor) loadIndex( if err != nil { return nil, err } + changesURL := base.JoinPath("changes.csv").String() - indexURL := base.JoinPath("index.txt").String() - resp, err := afp.client.Get(indexURL) + resp, err := afp.client.Get(changesURL) if err != nil { return nil, err } - defer resp.Body.Close() - var files []AdvisoryFile - scanner := bufio.NewScanner(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("fetching %s failed. Status code %d (%s)", + changesURL, resp.StatusCode, resp.Status) + } - for line := 1; scanner.Scan(); line++ { - u := scanner.Text() - if _, err := url.Parse(u); err != nil { - lg("index.txt contains invalid URL %q in line %d", u, line) + defer resp.Body.Close() + var files []AdvisoryFile + c := csv.NewReader(resp.Body) + const ( + pathColumn = 0 + timeColumn = 1 + ) + for line := 1; ; line++ { + r, err := c.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + if len(r) < 2 { + lg("%q has not enough columns in line %d", line) + continue + } + t, err := time.Parse(time.RFC3339, r[timeColumn]) + if err != nil { + lg("%q has an invalid time stamp in line %d: %v", changesURL, line, err) + continue + } + // Apply date range filtering. + if afp.AgeAccept != nil && !afp.AgeAccept(t) { + continue + } + path := r[pathColumn] + if _, err := url.Parse(path); err != nil { + lg("%q contains an invalid URL %q in line %d", changesURL, path, line) continue } files = append(files, - PlainAdvisoryFile(base.JoinPath(u).String())) - } - - if err := scanner.Err(); err != nil { - return nil, err + PlainAdvisoryFile(base.JoinPath(path).String())) } return files, nil } From 9e665a2fa153f3b220a2891a4aafed8ec868606d Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 04:18:54 +0200 Subject: [PATCH 06/12] Adjust docs --- docs/csaf_downloader.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/csaf_downloader.md b/docs/csaf_downloader.md index 250d53ba..19adbd90 100644 --- a/docs/csaf_downloader.md +++ b/docs/csaf_downloader.md @@ -14,6 +14,7 @@ Application Options: -v, --verbose Verbose output -r, --rate= The average upper limit of https operations per second (defaults to unlimited) -w, --worker=NUM NUMber of concurrent downloads (default: 2) + -t, --timerange=RANGE RANGE of time from which advisories to download -H, --header= One or more extra HTTP header fields --validator=URL URL to validate documents remotely --validatorcache=FILE FILE to cache remote validations @@ -54,4 +55,30 @@ worker = 2 # validator # not set by default # validatorcache # not set by default validatorpreset = ["mandatory"] +# timerange # not set by default ``` + +The `timerange` parameter enables downloading advisories which last changes falls +into a given intervall. There are three possible notations: + +1 - Relative. If the given string follows the rules of being a [Go duration](https://pkg.go.dev/time@go1.20.6#ParseDuration) + the time interval from now minus that duration till now is used. + E.g. `"3h"` means downloading the advisories that have changed in the last three hours. + +2 - Absolute. If the given string is an RFC 3339 date timestamp the time interval between + this date and now is used. + E.g. "2006-01-02" means that all files between 2006 January 2nd and now going to being + downloaded. Accepted patterns are: + - "2006-01-02T15:04:05Z07:00" + - "2006-01-02T15:04:05" + - "2006-01-02T15:04" + - "2006-01-02T15" + - "2006-01-02" + - "2006-01" + - "2006" + Missing parts are set to the smallest value possible in that field. + +3 - Range. Same as 2 but separated by a `,` to span an interval. e.g `2019,2024` + spans an interval from 1st January 2019 to the 1st January of 2024. + +All interval boundaries are inclusive. From 1f301b630132890de093120bf3471a1f1934235d Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 04:25:45 +0200 Subject: [PATCH 07/12] Prettifying docs --- docs/csaf_downloader.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/csaf_downloader.md b/docs/csaf_downloader.md index 19adbd90..19d59e04 100644 --- a/docs/csaf_downloader.md +++ b/docs/csaf_downloader.md @@ -61,24 +61,24 @@ validatorpreset = ["mandatory"] The `timerange` parameter enables downloading advisories which last changes falls into a given intervall. There are three possible notations: -1 - Relative. If the given string follows the rules of being a [Go duration](https://pkg.go.dev/time@go1.20.6#ParseDuration) +1. Relative. If the given string follows the rules of being a [Go duration](https://pkg.go.dev/time@go1.20.6#ParseDuration) the time interval from now minus that duration till now is used. E.g. `"3h"` means downloading the advisories that have changed in the last three hours. -2 - Absolute. If the given string is an RFC 3339 date timestamp the time interval between - this date and now is used. - E.g. "2006-01-02" means that all files between 2006 January 2nd and now going to being - downloaded. Accepted patterns are: - - "2006-01-02T15:04:05Z07:00" - - "2006-01-02T15:04:05" - - "2006-01-02T15:04" - - "2006-01-02T15" - - "2006-01-02" - - "2006-01" - - "2006" - Missing parts are set to the smallest value possible in that field. +2. Absolute. If the given string is an RFC 3339 date timestamp the time interval between + this date and now is used. + E.g. "2006-01-02" means that all files between 2006 January 2nd and now going to being + downloaded. Accepted patterns are: + - `"2006-01-02T15:04:05Z07:00"` + - `"2006-01-02T15:04:05"` + - `"2006-01-02T15:04"` + - `"2006-01-02T15"` + - `"2006-01-02"` + - `"2006-01"` + - `"2006"` + Missing parts are set to the smallest value possible in that field. -3 - Range. Same as 2 but separated by a `,` to span an interval. e.g `2019,2024` - spans an interval from 1st January 2019 to the 1st January of 2024. +3. Range. Same as 2 but separated by a `,` to span an interval. e.g `2019,2024` + spans an interval from 1st January 2019 to the 1st January of 2024. All interval boundaries are inclusive. From 975e350510a67d827d4a8fc0130cebee84c4f3ff Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 04:27:04 +0200 Subject: [PATCH 08/12] Prettifying docs --- docs/csaf_downloader.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/csaf_downloader.md b/docs/csaf_downloader.md index 19d59e04..6bab112a 100644 --- a/docs/csaf_downloader.md +++ b/docs/csaf_downloader.md @@ -76,6 +76,7 @@ into a given intervall. There are three possible notations: - `"2006-01-02"` - `"2006-01"` - `"2006"` + Missing parts are set to the smallest value possible in that field. 3. Range. Same as 2 but separated by a `,` to span an interval. e.g `2019,2024` From 5e6fb8241c8926c849d93599b973f89218f92482 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 04:28:42 +0200 Subject: [PATCH 09/12] Prettifying docs --- docs/csaf_downloader.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/csaf_downloader.md b/docs/csaf_downloader.md index 6bab112a..64fc9832 100644 --- a/docs/csaf_downloader.md +++ b/docs/csaf_downloader.md @@ -67,8 +67,9 @@ into a given intervall. There are three possible notations: 2. Absolute. If the given string is an RFC 3339 date timestamp the time interval between this date and now is used. - E.g. "2006-01-02" means that all files between 2006 January 2nd and now going to being - downloaded. Accepted patterns are: + E.g. `"2006-01-02"` means that all files between 2006 January 2nd and now going to being + downloaded. + Accepted patterns are: - `"2006-01-02T15:04:05Z07:00"` - `"2006-01-02T15:04:05"` - `"2006-01-02T15:04"` From eade9f7ae4fda83ee836a0c2db824092387c8424 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 10:52:20 +0200 Subject: [PATCH 10/12] Fixed switched time.Parse args. --- internal/models/models.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/models/models.go b/internal/models/models.go index 37e1daae..7c3a8985 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -38,7 +38,7 @@ func guessDate(s string) (time.Time, bool) { "2006-01", "2006", } { - if t, err := time.Parse(s, layout); err == nil { + if t, err := time.Parse(layout, s); err == nil { return t, true } } @@ -66,6 +66,7 @@ func (tr *TimeRange) UnmarshalText(text []byte) error { return fmt.Errorf("%q is not a valid RFC date time", a) } *tr = NewTimeInterval(start, time.Now()) + return nil } // Real interval start, ok := guessDate(a) From 1d892ff681d34890942e49ce44c7a5e9b3400532 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 11:07:30 +0200 Subject: [PATCH 11/12] Fix docs. --- docs/csaf_downloader.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/csaf_downloader.md b/docs/csaf_downloader.md index 64fc9832..8874df13 100644 --- a/docs/csaf_downloader.md +++ b/docs/csaf_downloader.md @@ -70,7 +70,9 @@ into a given intervall. There are three possible notations: E.g. `"2006-01-02"` means that all files between 2006 January 2nd and now going to being downloaded. Accepted patterns are: - - `"2006-01-02T15:04:05Z07:00"` + - `"2006-01-02T15:04:05Z"` + - `"2006-01-02T15:04:05+07:00"` + - `"2006-01-02T15:04:05-07:00"` - `"2006-01-02T15:04:05"` - `"2006-01-02T15:04"` - `"2006-01-02T15"` From 125028773fdc5d4a7f3f49991b55f92e16552655 Mon Sep 17 00:00:00 2001 From: "Sascha L. Teichmann" Date: Wed, 26 Jul 2023 12:06:16 +0200 Subject: [PATCH 12/12] go-flag needs its own Unmarshaler. --- internal/models/models.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/models/models.go b/internal/models/models.go index 7c3a8985..a7f6b02a 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -47,8 +47,13 @@ func guessDate(s string) (time.Time, bool) { // UnmarshalText implements [encoding/text.TextUnmarshaler]. func (tr *TimeRange) UnmarshalText(text []byte) error { + return tr.UnmarshalFlag(string(text)) +} + +// UnmarshalFlag implements [go-flags/Unmarshaler]. +func (tr *TimeRange) UnmarshalFlag(s string) error { + s = strings.TrimSpace(s) - s := strings.TrimSpace(string(text)) // Handle relative case first. if duration, err := time.ParseDuration(s); err == nil { now := time.Now()