package step import "os" import "io" import "fmt" import "time" import "io/fs" import "plugin" import "errors" import "syscall" import "context" import "path/filepath" import "html/template" import "git.tebibyte.media/sashakoshka/goutil/sync" // TODO change Parse to Load, ParseReader to Parse // Environment is an execution environment for STEP documents. It can parse // documents into itself and resolve dependencies among them. type Environment struct { // FS, if specified, is the filesystem that this environment will load // documents from. Its methods must be safe for concurrent execution. FS fs.FS // Providers is a slice of objects which can provide various // functionality to the environment. In order to be used, values in this // slice must implement one or more of the interfaces in provider.go. Providers []Provider // Config specifies configuration data. It is used to configure the // environment, as well as plugins that get loaded. Config Meta documents usync.Locker[map[string] *Document] funcMap template.FuncMap } // Init must be called before utilizng the environment. func (this *Environment) Init (ctx context.Context) error { this.documents = usync.NewLocker(make(map[string] *Document)) this.funcMap = make(template.FuncMap) for _, provider := range this.Providers { if provider, ok := provider.(ConfigProcessor); ok { err := provider.ProcessConfig(this.Config) if err != nil { return err } } } for _, provider := range this.Providers { if provider, ok := provider.(Configurable); ok { err := provider.Configure(this.Config) if err != nil { return err } } if provider, ok := provider.(FuncProvider); ok { funcMap := provider.FuncMap() if funcMap == nil { continue } for name, function := range funcMap { this.funcMap[name] = function } } } return ctx.Err() } // Parse parses the named document and returns it. The name is treated as a file // path relative to the current working directory. Documents are cached into the // environment once parsed, and are returned on subsequent calls that have the // same name. func (this *Environment) Parse (name string) (*Document, error) { return this.ParseRelative(name, nil) } // ParseRelative is like Parse, but treats the name as a path relative to the // given document. func (this *Environment) ParseRelative (name string, document *Document) (*Document, error) { if document == nil { name = filepath.Clean(name) } else { nam, err := document.Rel(name) if err != nil { return nil, err } name = nam } input, err := this.GetFS().Open(name) if err != nil { // if the file couldn't be opened, delete from cache in case the // file was deleted this.Unload(name) return nil, err } defer input.Close() info, err := input.Stat() if err != nil { return nil, err } return this.parse(name, info.ModTime(), input) } // ParseReader is like Parse, but parses a reader and just takes your word for // it that it corresponds to the file of the given name. It always re-parses. func (this *Environment) ParseReader (name string, input io.Reader) (*Document, error) { name = filepath.Clean(name) return this.parse(name, time.Now(), input) } // Unload removes a named document. It will be reloaded if necessary. func (this *Environment) Unload (name string) { documents, done := this.documents.Borrow() defer done() delete(documents, name) } // LoadProviderPlugin loads a plugin given its file path. The file must: // // - Be a shared library // - Be built with go -buildmode=plugin // - Be built with the same version of Go // - Be built with the same version of STEP // - Be owned by root // // Plugins cannot be unloaded from the current program once they are loaded. // Sorgy :( its Go's fault. func (this *Environment) LoadProviderPlugin (name string) (Provider, error) { return this.loadProviderPlugin(name, true) } // LoadProviderPluginUnsafe is like LoadProviderPlugin, but does not check to // see that the file is owned by root, thereby making it easier to run a random // plugin you just compiled. This should not be used otherwise. func (this *Environment) LoadProviderPluginUnsafe (name string) (Provider, error) { return this.loadProviderPlugin(name, false) } func (this *Environment) loadProviderPlugin (name string, checkRoot bool) (Provider, error) { plugin, err := this.loadPlugin(name, checkRoot) if err != nil { return nil, err } providerSymbol, err := plugin.Lookup("Provider") if err != nil { return nil, err } providerFactory, ok := providerSymbol.(func () Provider) if !ok { return nil, ErrPluginBadSymbol } provider := providerFactory() this.Providers = append(this.Providers, provider) return provider, nil } func (this *Environment) loadPlugin (name string, checkRoot bool) (*plugin.Plugin, error) { name = filepath.Clean(name) if checkRoot { info, err := os.Stat(name) if err != nil { return nil, err } if info, ok := info.Sys().(*syscall.Stat_t); ok { if info.Uid != 0 { return nil, ErrPluginNotOwnedByRoot } } else { return nil, ErrInsufficientSystem } } return plugin.Open(name) } func (this *Environment) parse (name string, modTime time.Time, input io.Reader) (*Document, error) { documents, done := this.documents.Borrow() defer done() // check cache if document, ok := documents[name]; ok { if modTime.Before(document.parseTime) { return document, nil } } // read entire file and split into front matter and body buffer, err := io.ReadAll(input) if err != nil { return nil, err } meta, body, err := SplitMeta(string(buffer)) if err != nil { return nil, err } // assemble the document struct document := &Document { Meta: meta, environment: this, name: name, parseTime: time.Now(), template: template.New(name), } if author := meta.Get("author"); author != "" { document.Author = author } if title := meta.Get("title"); title != "" { document.Title = title } if extends := meta.Get("extends"); extends != "" { document.Extends = extends } // add template functions funcMap := make(template.FuncMap) for name, function := range this.funcMap { funcMap[name] = function } for _, provider := range this.Providers { provider, ok := provider.(FuncProviderFor) if !ok { continue } funcMapFor := provider.FuncMapFor(document) if funcMapFor == nil { continue } for name, function := range funcMapFor { funcMap[name] = function } } err = this.addFuncsTo(document, funcMap) if err != nil { return nil, err } // parse template from the body // TODO catch template errors here and offset their row number by the // number of rows taken up by the front matter _, err = document.template.Parse(body) if err != nil { return nil, err } documents[name] = document return document, nil } func (this *Environment) addFuncsTo (document *Document, funcs template.FuncMap) (err error) { defer func () { if r := recover(); r != nil { if rerr, ok := err.(error); ok { err = rerr } else { err = errors.New(fmt.Sprint(r)) } } } () document.template.Funcs(funcs) return err } // GetFS returns the effective filesystem that the environment uses. func (this *Environment) GetFS () fs.FS { if this.FS == nil { return os.DirFS(".") } return this.FS }