Initial commit

This commit is contained in:
2024-01-22 13:36:06 +03:00
commit bb8e6e79b2
82 changed files with 11517 additions and 0 deletions

172
internal/cliutils/prompt.go Normal file
View File

@ -0,0 +1,172 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package cliutils
import (
"context"
"os"
"strings"
"github.com/AlecAivazis/survey/v2"
"lure.sh/lure/internal/config"
"lure.sh/lure/internal/db"
"lure.sh/lure/internal/pager"
"lure.sh/lure/internal/translations"
"lure.sh/lure/pkg/loggerctx"
)
// YesNoPrompt asks the user a yes or no question, using def as the default answer
func YesNoPrompt(ctx context.Context, msg string, interactive, def bool) (bool, error) {
if interactive {
var answer bool
err := survey.AskOne(
&survey.Confirm{
Message: translations.Translator(ctx).TranslateTo(msg, config.Language(ctx)),
Default: def,
},
&answer,
)
return answer, err
} else {
return def, nil
}
}
// PromptViewScript asks the user if they'd like to see a script,
// shows it if they answer yes, then asks if they'd still like to
// continue, and exits if they answer no.
func PromptViewScript(ctx context.Context, script, name, style string, interactive bool) error {
log := loggerctx.From(ctx)
if !interactive {
return nil
}
scriptPrompt := translations.Translator(ctx).TranslateTo("Would you like to view the build script for", config.Language(ctx)) + " " + name
view, err := YesNoPrompt(ctx, scriptPrompt, interactive, false)
if err != nil {
return err
}
if view {
err = ShowScript(script, name, style)
if err != nil {
return err
}
cont, err := YesNoPrompt(ctx, "Would you still like to continue?", interactive, false)
if err != nil {
return err
}
if !cont {
log.Fatal(translations.Translator(ctx).TranslateTo("User chose not to continue after reading script", config.Language(ctx))).Send()
}
}
return nil
}
// ShowScript uses the built-in pager to display a script at a
// given path, in the given syntax highlighting style.
func ShowScript(path, name, style string) error {
scriptFl, err := os.Open(path)
if err != nil {
return err
}
defer scriptFl.Close()
str, err := pager.SyntaxHighlightBash(scriptFl, style)
if err != nil {
return err
}
pgr := pager.New(name, str)
return pgr.Run()
}
// FlattenPkgs attempts to flatten the a map of slices of packages into a single slice
// of packages by prompting the user if multiple packages match.
func FlattenPkgs(ctx context.Context, found map[string][]db.Package, verb string, interactive bool) []db.Package {
log := loggerctx.From(ctx)
var outPkgs []db.Package
for _, pkgs := range found {
if len(pkgs) > 1 && interactive {
choice, err := PkgPrompt(ctx, pkgs, verb, interactive)
if err != nil {
log.Fatal("Error prompting for choice of package").Send()
}
outPkgs = append(outPkgs, choice)
} else if len(pkgs) == 1 || !interactive {
outPkgs = append(outPkgs, pkgs[0])
}
}
return outPkgs
}
// PkgPrompt asks the user to choose between multiple packages.
func PkgPrompt(ctx context.Context, options []db.Package, verb string, interactive bool) (db.Package, error) {
if !interactive {
return options[0], nil
}
names := make([]string, len(options))
for i, option := range options {
names[i] = option.Repository + "/" + option.Name + " " + option.Version
}
prompt := &survey.Select{
Options: names,
Message: translations.Translator(ctx).TranslateTo("Choose which package to "+verb, config.Language(ctx)),
}
var choice int
err := survey.AskOne(prompt, &choice)
if err != nil {
return db.Package{}, err
}
return options[choice], nil
}
// ChooseOptDepends asks the user to choose between multiple optional dependencies.
// The user may choose multiple items.
func ChooseOptDepends(ctx context.Context, options []string, verb string, interactive bool) ([]string, error) {
if !interactive {
return []string{}, nil
}
prompt := &survey.MultiSelect{
Options: options,
Message: translations.Translator(ctx).TranslateTo("Choose which optional package(s) to install", config.Language(ctx)),
}
var choices []int
err := survey.AskOne(prompt, &choices)
if err != nil {
return nil, err
}
out := make([]string, len(choices))
for i, choiceIndex := range choices {
out[i], _, _ = strings.Cut(options[choiceIndex], ": ")
}
return out, nil
}

79
internal/config/config.go Normal file
View File

@ -0,0 +1,79 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package config
import (
"context"
"os"
"sync"
"github.com/pelletier/go-toml/v2"
"lure.sh/lure/internal/types"
"lure.sh/lure/pkg/loggerctx"
)
var defaultConfig = &types.Config{
RootCmd: "sudo",
PagerStyle: "native",
IgnorePkgUpdates: []string{},
Repos: []types.Repo{
{
Name: "default",
URL: "https://github.com/lure-sh/lure-repo.git",
},
},
}
var (
configMtx sync.Mutex
config *types.Config
)
// Config returns a LURE configuration struct.
// The first time it's called, it'll load the config from a file.
// Subsequent calls will just return the same value.
func Config(ctx context.Context) *types.Config {
configMtx.Lock()
defer configMtx.Unlock()
log := loggerctx.From(ctx)
if config == nil {
cfgFl, err := os.Open(GetPaths(ctx).ConfigPath)
if err != nil {
log.Warn("Error opening config file, using defaults").Err(err).Send()
return defaultConfig
}
defer cfgFl.Close()
// Copy the default configuration into config
defCopy := *defaultConfig
config = &defCopy
config.Repos = nil
err = toml.NewDecoder(cfgFl).Decode(config)
if err != nil {
log.Warn("Error decoding config file, using defaults").Err(err).Send()
// Set config back to nil so that we try again next time
config = nil
return defaultConfig
}
}
return config
}

67
internal/config/lang.go Normal file
View File

@ -0,0 +1,67 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package config
import (
"context"
"os"
"strings"
"sync"
"lure.sh/lure/pkg/loggerctx"
"golang.org/x/text/language"
)
var (
langMtx sync.Mutex
lang language.Tag
langSet bool
)
// Language returns the system language.
// The first time it's called, it'll detect the langauge based on
// the $LANG environment variable.
// Subsequent calls will just return the same value.
func Language(ctx context.Context) language.Tag {
langMtx.Lock()
defer langMtx.Unlock()
log := loggerctx.From(ctx)
if !langSet {
syslang := SystemLang()
tag, err := language.Parse(syslang)
if err != nil {
log.Fatal("Error parsing system language").Err(err).Send()
}
base, _ := tag.Base()
lang = language.Make(base.String())
langSet = true
}
return lang
}
// SystemLang returns the system language based on
// the $LANG environment variable.
func SystemLang() string {
lang := os.Getenv("LANG")
lang, _, _ = strings.Cut(lang, ".")
if lang == "" || lang == "C" {
lang = "en"
}
return lang
}

108
internal/config/paths.go Normal file
View File

@ -0,0 +1,108 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package config
import (
"context"
"os"
"path/filepath"
"sync"
"github.com/pelletier/go-toml/v2"
"lure.sh/lure/pkg/loggerctx"
)
// Paths contains various paths used by LURE
type Paths struct {
ConfigDir string
ConfigPath string
CacheDir string
RepoDir string
PkgsDir string
DBPath string
}
var (
pathsMtx sync.Mutex
paths *Paths
)
// GetPaths returns a Paths struct.
// The first time it's called, it'll generate the struct
// using information from the system.
// Subsequent calls will return the same value.
func GetPaths(ctx context.Context) *Paths {
pathsMtx.Lock()
defer pathsMtx.Unlock()
log := loggerctx.From(ctx)
if paths == nil {
paths = &Paths{}
cfgDir, err := os.UserConfigDir()
if err != nil {
log.Fatal("Unable to detect user config directory").Err(err).Send()
}
paths.ConfigDir = filepath.Join(cfgDir, "lure")
err = os.MkdirAll(paths.ConfigDir, 0o755)
if err != nil {
log.Fatal("Unable to create LURE config directory").Err(err).Send()
}
paths.ConfigPath = filepath.Join(paths.ConfigDir, "lure.toml")
if _, err := os.Stat(paths.ConfigPath); err != nil {
cfgFl, err := os.Create(paths.ConfigPath)
if err != nil {
log.Fatal("Unable to create LURE config file").Err(err).Send()
}
err = toml.NewEncoder(cfgFl).Encode(&defaultConfig)
if err != nil {
log.Fatal("Error encoding default configuration").Err(err).Send()
}
cfgFl.Close()
}
cacheDir, err := os.UserCacheDir()
if err != nil {
log.Fatal("Unable to detect cache directory").Err(err).Send()
}
paths.CacheDir = filepath.Join(cacheDir, "lure")
paths.RepoDir = filepath.Join(paths.CacheDir, "repo")
paths.PkgsDir = filepath.Join(paths.CacheDir, "pkgs")
err = os.MkdirAll(paths.RepoDir, 0o755)
if err != nil {
log.Fatal("Unable to create repo cache directory").Err(err).Send()
}
err = os.MkdirAll(paths.PkgsDir, 0o755)
if err != nil {
log.Fatal("Unable to create package cache directory").Err(err).Send()
}
paths.DBPath = filepath.Join(paths.CacheDir, "db")
}
return paths
}

View File

@ -0,0 +1,5 @@
package config
// Version contains the version of LURE. If the version
// isn't known, it'll be set to "unknown"
var Version = "unknown"

117
internal/cpu/cpu.go Normal file
View File

@ -0,0 +1,117 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package cpu
import (
"os"
"runtime"
"strconv"
"strings"
"golang.org/x/exp/slices"
"golang.org/x/sys/cpu"
)
// armVariant checks which variant of ARM lure is running
// on, by using the same detection method as Go itself
func armVariant() string {
armEnv := os.Getenv("LURE_ARM_VARIANT")
// ensure value has "arm" prefix, such as arm5 or arm6
if strings.HasPrefix(armEnv, "arm") {
return armEnv
}
if cpu.ARM.HasVFPv3 {
return "arm7"
} else if cpu.ARM.HasVFP {
return "arm6"
} else {
return "arm5"
}
}
// Arch returns the canonical CPU architecture of the system
func Arch() string {
arch := os.Getenv("LURE_ARCH")
if arch == "" {
arch = runtime.GOARCH
}
if arch == "arm" {
arch = armVariant()
}
return arch
}
func IsCompatibleWith(target string, list []string) bool {
if target == "all" || slices.Contains(list, "all") {
return true
}
for _, arch := range list {
if strings.HasPrefix(target, "arm") && strings.HasPrefix(arch, "arm") {
targetVer, err := getARMVersion(target)
if err != nil {
return false
}
archVer, err := getARMVersion(arch)
if err != nil {
return false
}
if targetVer >= archVer {
return true
}
}
if target == arch {
return true
}
}
return false
}
func CompatibleArches(arch string) ([]string, error) {
if strings.HasPrefix(arch, "arm") {
ver, err := getARMVersion(arch)
if err != nil {
return nil, err
}
if ver > 5 {
var out []string
for i := ver; i >= 5; i-- {
out = append(out, "arm"+strconv.Itoa(i))
}
return out, nil
}
}
return []string{arch}, nil
}
func getARMVersion(arch string) (int, error) {
// Extract the version number from ARM architecture
version := strings.TrimPrefix(arch, "arm")
if version == "" {
return 5, nil // Default to arm5 if version is not specified
}
return strconv.Atoi(version)
}

346
internal/db/db.go Normal file
View File

@ -0,0 +1,346 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package db
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"sync"
"github.com/jmoiron/sqlx"
"lure.sh/lure/internal/config"
"lure.sh/lure/pkg/loggerctx"
"golang.org/x/exp/slices"
"modernc.org/sqlite"
)
// CurrentVersion is the current version of the database.
// The database is reset if its version doesn't match this.
const CurrentVersion = 2
func init() {
sqlite.MustRegisterScalarFunction("json_array_contains", 2, jsonArrayContains)
}
// Package is a LURE package's database representation
type Package struct {
Name string `sh:"name,required" db:"name"`
Version string `sh:"version,required" db:"version"`
Release int `sh:"release,required" db:"release"`
Epoch uint `sh:"epoch" db:"epoch"`
Description JSON[map[string]string] `db:"description"`
Homepage JSON[map[string]string] `db:"homepage"`
Maintainer JSON[map[string]string] `db:"maintainer"`
Architectures JSON[[]string] `sh:"architectures" db:"architectures"`
Licenses JSON[[]string] `sh:"license" db:"licenses"`
Provides JSON[[]string] `sh:"provides" db:"provides"`
Conflicts JSON[[]string] `sh:"conflicts" db:"conflicts"`
Replaces JSON[[]string] `sh:"replaces" db:"replaces"`
Depends JSON[map[string][]string] `db:"depends"`
BuildDepends JSON[map[string][]string] `db:"builddepends"`
OptDepends JSON[map[string][]string] `db:"optdepends"`
Repository string `db:"repository"`
}
type version struct {
Version int `db:"version"`
}
var (
mu sync.Mutex
conn *sqlx.DB
closed = true
)
// DB returns the LURE database.
// The first time it's called, it opens the SQLite database file.
// Subsequent calls return the same connection.
func DB(ctx context.Context) *sqlx.DB {
log := loggerctx.From(ctx)
if conn != nil && !closed {
return getConn()
}
_, err := open(ctx, config.GetPaths(ctx).DBPath)
if err != nil {
log.Fatal("Error opening database").Err(err).Send()
}
return getConn()
}
func getConn() *sqlx.DB {
mu.Lock()
defer mu.Unlock()
return conn
}
func open(ctx context.Context, dsn string) (*sqlx.DB, error) {
db, err := sqlx.Open("sqlite", dsn)
if err != nil {
return nil, err
}
mu.Lock()
conn = db
closed = false
mu.Unlock()
err = initDB(ctx, dsn)
if err != nil {
return nil, err
}
return db, nil
}
// Close closes the database
func Close() error {
closed = true
if conn != nil {
return conn.Close()
} else {
return nil
}
}
// initDB initializes the database
func initDB(ctx context.Context, dsn string) error {
log := loggerctx.From(ctx)
conn = conn.Unsafe()
_, err := conn.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS pkgs (
name TEXT NOT NULL,
repository TEXT NOT NULL,
version TEXT NOT NULL,
release INT NOT NULL,
epoch INT,
description TEXT CHECK(description = 'null' OR (JSON_VALID(description) AND JSON_TYPE(description) = 'object')),
homepage TEXT CHECK(homepage = 'null' OR (JSON_VALID(homepage) AND JSON_TYPE(homepage) = 'object')),
maintainer TEXT CHECK(maintainer = 'null' OR (JSON_VALID(maintainer) AND JSON_TYPE(maintainer) = 'object')),
architectures TEXT CHECK(architectures = 'null' OR (JSON_VALID(architectures) AND JSON_TYPE(architectures) = 'array')),
licenses TEXT CHECK(licenses = 'null' OR (JSON_VALID(licenses) AND JSON_TYPE(licenses) = 'array')),
provides TEXT CHECK(provides = 'null' OR (JSON_VALID(provides) AND JSON_TYPE(provides) = 'array')),
conflicts TEXT CHECK(conflicts = 'null' OR (JSON_VALID(conflicts) AND JSON_TYPE(conflicts) = 'array')),
replaces TEXT CHECK(replaces = 'null' OR (JSON_VALID(replaces) AND JSON_TYPE(replaces) = 'array')),
depends TEXT CHECK(depends = 'null' OR (JSON_VALID(depends) AND JSON_TYPE(depends) = 'object')),
builddepends TEXT CHECK(builddepends = 'null' OR (JSON_VALID(builddepends) AND JSON_TYPE(builddepends) = 'object')),
optdepends TEXT CHECK(optdepends = 'null' OR (JSON_VALID(optdepends) AND JSON_TYPE(optdepends) = 'object')),
UNIQUE(name, repository)
);
CREATE TABLE IF NOT EXISTS lure_db_version (
version INT NOT NULL
);
`)
if err != nil {
return err
}
ver, ok := GetVersion(ctx)
if ok && ver != CurrentVersion {
log.Warn("Database version mismatch; resetting").Int("version", ver).Int("expected", CurrentVersion).Send()
reset(ctx)
return initDB(ctx, dsn)
} else if !ok {
log.Warn("Database version does not exist. Run lure fix if something isn't working.").Send()
return addVersion(ctx, CurrentVersion)
}
return nil
}
// reset drops all the database tables
func reset(ctx context.Context) error {
_, err := DB(ctx).ExecContext(ctx, "DROP TABLE IF EXISTS pkgs;")
if err != nil {
return err
}
_, err = DB(ctx).ExecContext(ctx, "DROP TABLE IF EXISTS lure_db_version;")
return err
}
// IsEmpty returns true if the database has no packages in it, otherwise it returns false.
func IsEmpty(ctx context.Context) bool {
var count int
err := DB(ctx).GetContext(ctx, &count, "SELECT count(1) FROM pkgs;")
if err != nil {
return true
}
return count == 0
}
// GetVersion returns the database version and a boolean indicating
// whether the database contained a version number
func GetVersion(ctx context.Context) (int, bool) {
var ver version
err := DB(ctx).GetContext(ctx, &ver, "SELECT * FROM lure_db_version LIMIT 1;")
if err != nil {
return 0, false
}
return ver.Version, true
}
func addVersion(ctx context.Context, ver int) error {
_, err := DB(ctx).ExecContext(ctx, `INSERT INTO lure_db_version(version) VALUES (?);`, ver)
return err
}
// InsertPackage adds a package to the database
func InsertPackage(ctx context.Context, pkg Package) error {
_, err := DB(ctx).NamedExecContext(ctx, `
INSERT OR REPLACE INTO pkgs (
name,
repository,
version,
release,
epoch,
description,
homepage,
maintainer,
architectures,
licenses,
provides,
conflicts,
replaces,
depends,
builddepends,
optdepends
) VALUES (
:name,
:repository,
:version,
:release,
:epoch,
:description,
:homepage,
:maintainer,
:architectures,
:licenses,
:provides,
:conflicts,
:replaces,
:depends,
:builddepends,
:optdepends
);
`, pkg)
return err
}
// GetPkgs returns a result containing packages that match the where conditions
func GetPkgs(ctx context.Context, where string, args ...any) (*sqlx.Rows, error) {
stream, err := DB(ctx).QueryxContext(ctx, "SELECT * FROM pkgs WHERE "+where, args...)
if err != nil {
return nil, err
}
return stream, nil
}
// GetPkg returns a single package that matches the where conditions
func GetPkg(ctx context.Context, where string, args ...any) (*Package, error) {
out := &Package{}
err := DB(ctx).GetContext(ctx, out, "SELECT * FROM pkgs WHERE "+where+" LIMIT 1", args...)
return out, err
}
// DeletePkgs deletes all packages matching the where conditions
func DeletePkgs(ctx context.Context, where string, args ...any) error {
_, err := DB(ctx).ExecContext(ctx, "DELETE FROM pkgs WHERE "+where, args...)
return err
}
// jsonArrayContains is an SQLite function that checks if a JSON array
// in the database contains a given value
func jsonArrayContains(ctx *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) {
value, ok := args[0].(string)
if !ok {
return nil, errors.New("both arguments to json_array_contains must be strings")
}
item, ok := args[1].(string)
if !ok {
return nil, errors.New("both arguments to json_array_contains must be strings")
}
var array []string
err := json.Unmarshal([]byte(value), &array)
if err != nil {
return nil, err
}
return slices.Contains(array, item), nil
}
// JSON represents a JSON value in the database
type JSON[T any] struct {
Val T
}
// NewJSON creates a new database JSON value
func NewJSON[T any](v T) JSON[T] {
return JSON[T]{Val: v}
}
func (s *JSON[T]) Scan(val any) error {
if val == nil {
return nil
}
switch val := val.(type) {
case string:
err := json.Unmarshal([]byte(val), &s.Val)
if err != nil {
return err
}
case sql.NullString:
if val.Valid {
err := json.Unmarshal([]byte(val.String), &s.Val)
if err != nil {
return err
}
}
default:
return errors.New("sqlite json types must be strings")
}
return nil
}
func (s JSON[T]) Value() (driver.Value, error) {
data, err := json.Marshal(s.Val)
if err != nil {
return nil, err
}
return string(data), nil
}
func (s JSON[T]) MarshalYAML() (any, error) {
return s.Val, nil
}
func (s JSON[T]) String() string {
return fmt.Sprint(s.Val)
}
func (s JSON[T]) GoString() string {
return fmt.Sprintf("%#v", s.Val)
}

250
internal/db/db_test.go Normal file
View File

@ -0,0 +1,250 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package db_test
import (
"reflect"
"strings"
"testing"
"github.com/jmoiron/sqlx"
"lure.sh/lure/internal/db"
)
var testPkg = db.Package{
Name: "test",
Version: "0.0.1",
Release: 1,
Epoch: 2,
Description: db.NewJSON(map[string]string{
"en": "Test package",
"ru": "Проверочный пакет",
}),
Homepage: db.NewJSON(map[string]string{
"en": "https://lure.sh/",
}),
Maintainer: db.NewJSON(map[string]string{
"en": "Elara Musayelyan <elara@elara.ws>",
"ru": "Элара Мусаелян <elara@elara.ws>",
}),
Architectures: db.NewJSON([]string{"arm64", "amd64"}),
Licenses: db.NewJSON([]string{"GPL-3.0-or-later"}),
Provides: db.NewJSON([]string{"test"}),
Conflicts: db.NewJSON([]string{"test"}),
Replaces: db.NewJSON([]string{"test-old"}),
Depends: db.NewJSON(map[string][]string{
"": {"sudo"},
}),
BuildDepends: db.NewJSON(map[string][]string{
"": {"golang"},
"arch": {"go"},
}),
Repository: "default",
}
func TestInit(t *testing.T) {
_, err := db.Open(":memory:")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
defer db.Close()
_, err = db.DB().Exec("SELECT * FROM pkgs")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
ver, ok := db.GetVersion()
if !ok {
t.Errorf("Expected version to be present")
} else if ver != db.CurrentVersion {
t.Errorf("Expected version %d, got %d", db.CurrentVersion, ver)
}
}
func TestInsertPackage(t *testing.T) {
_, err := db.Open(":memory:")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
defer db.Close()
err = db.InsertPackage(testPkg)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
dbPkg := db.Package{}
err = sqlx.Get(db.DB(), &dbPkg, "SELECT * FROM pkgs WHERE name = 'test' AND repository = 'default'")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if !reflect.DeepEqual(testPkg, dbPkg) {
t.Errorf("Expected test package to be the same as database package")
}
}
func TestGetPkgs(t *testing.T) {
_, err := db.Open(":memory:")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
defer db.Close()
x1 := testPkg
x1.Name = "x1"
x2 := testPkg
x2.Name = "x2"
err = db.InsertPackage(x1)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
err = db.InsertPackage(x2)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
result, err := db.GetPkgs("name LIKE 'x%'")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
for result.Next() {
var dbPkg db.Package
err = result.StructScan(&dbPkg)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
if !strings.HasPrefix(dbPkg.Name, "x") {
t.Errorf("Expected package name to start with 'x', got %s", dbPkg.Name)
}
}
}
func TestGetPkg(t *testing.T) {
_, err := db.Open(":memory:")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
defer db.Close()
x1 := testPkg
x1.Name = "x1"
x2 := testPkg
x2.Name = "x2"
err = db.InsertPackage(x1)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
err = db.InsertPackage(x2)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
pkg, err := db.GetPkg("name LIKE 'x%' ORDER BY name")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if pkg.Name != "x1" {
t.Errorf("Expected x1 package, got %s", pkg.Name)
}
if !reflect.DeepEqual(*pkg, x1) {
t.Errorf("Expected x1 to be %v, got %v", x1, *pkg)
}
}
func TestDeletePkgs(t *testing.T) {
_, err := db.Open(":memory:")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
defer db.Close()
x1 := testPkg
x1.Name = "x1"
x2 := testPkg
x2.Name = "x2"
err = db.InsertPackage(x1)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
err = db.InsertPackage(x2)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
err = db.DeletePkgs("name = 'x1'")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
var dbPkg db.Package
err = db.DB().Get(&dbPkg, "SELECT * FROM pkgs WHERE name LIKE 'x%' ORDER BY name LIMIT 1;")
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
if dbPkg.Name != "x2" {
t.Errorf("Expected x2 package, got %s", dbPkg.Name)
}
}
func TestJsonArrayContains(t *testing.T) {
_, err := db.Open(":memory:")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
defer db.Close()
x1 := testPkg
x1.Name = "x1"
x2 := testPkg
x2.Name = "x2"
x2.Provides.Val = append(x2.Provides.Val, "x")
err = db.InsertPackage(x1)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
err = db.InsertPackage(x2)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
var dbPkg db.Package
err = db.DB().Get(&dbPkg, "SELECT * FROM pkgs WHERE json_array_contains(provides, 'x');")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if dbPkg.Name != "x2" {
t.Errorf("Expected x2 package, got %s", dbPkg.Name)
}
}

379
internal/dl/dl.go Normal file
View File

@ -0,0 +1,379 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
// Package dl contains abstractions for downloadingfiles and directories
// from various sources.
package dl
import (
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"errors"
"fmt"
"hash"
"io"
"os"
"path/filepath"
"strings"
"github.com/PuerkitoBio/purell"
"github.com/vmihailenco/msgpack/v5"
"golang.org/x/crypto/blake2b"
"golang.org/x/crypto/blake2s"
"golang.org/x/exp/slices"
"lure.sh/lure/internal/dlcache"
"lure.sh/lure/pkg/loggerctx"
)
const manifestFileName = ".lure_cache_manifest"
// ErrChecksumMismatch occurs when the checksum of a downloaded file
// does not match the expected checksum provided in the Options struct.
var (
ErrChecksumMismatch = errors.New("dl: checksums did not match")
ErrNoSuchHashAlgo = errors.New("dl: invalid hashing algorithm")
)
// Downloaders contains all the downloaders in the order in which
// they should be checked
var Downloaders = []Downloader{
GitDownloader{},
TorrentDownloader{},
FileDownloader{},
}
// Type represents the type of download (file or directory)
type Type uint8
const (
TypeFile Type = iota
TypeDir
)
func (t Type) String() string {
switch t {
case TypeFile:
return "file"
case TypeDir:
return "dir"
}
return "<unknown>"
}
// Options contains the options for downloading
// files and directories
type Options struct {
Hash []byte
HashAlgorithm string
Name string
URL string
Destination string
CacheDisabled bool
PostprocDisabled bool
Progress io.Writer
LocalDir string
}
func (opts Options) NewHash() (hash.Hash, error) {
switch opts.HashAlgorithm {
case "", "sha256":
return sha256.New(), nil
case "sha224":
return sha256.New224(), nil
case "sha512":
return sha512.New(), nil
case "sha384":
return sha512.New384(), nil
case "sha1":
return sha1.New(), nil
case "md5":
return md5.New(), nil
case "blake2s-128":
return blake2s.New256(nil)
case "blake2s-256":
return blake2s.New256(nil)
case "blake2b-256":
return blake2b.New(32, nil)
case "blake2b-512":
return blake2b.New(64, nil)
default:
return nil, fmt.Errorf("%w: %s", ErrNoSuchHashAlgo, opts.HashAlgorithm)
}
}
// Manifest holds information about the type and name
// of a downloaded file or directory. It is stored inside
// each cache directory for later use.
type Manifest struct {
Type Type
Name string
}
type Downloader interface {
// Name returns the name of the downloader
Name() string
// MatchURL checks if the given URL matches
// the downloader.
MatchURL(string) bool
// Download downloads the object at the URL
// provided in the options, to the destination
// given in the options. It returns a type,
// a name for the downloaded object (this may be empty),
// and an error.
Download(Options) (Type, string, error)
}
// UpdatingDownloader extends the Downloader interface
// with an Update method for protocols such as git, which
// allow for incremental updates without changing the URL.
type UpdatingDownloader interface {
Downloader
// Update checks for and performs any
// available updates for the object
// described in the options. It returns
// true if an update was performed, or
// false if no update was required.
Update(Options) (bool, error)
}
// Download downloads a file or directory using the specified options.
// It first gets the appropriate downloader for the URL, then checks
// if caching is enabled. If caching is enabled, it attempts to get
// the cache directory for the URL and update it if necessary.
// If the source is found in the cache, it links it to the destination
// using hard links. If the source is not found in the cache,
// it downloads the source to a new cache directory and links it
// to the destination.
func Download(ctx context.Context, opts Options) (err error) {
log := loggerctx.From(ctx)
normalized, err := normalizeURL(opts.URL)
if err != nil {
return err
}
opts.URL = normalized
d := getDownloader(opts.URL)
if opts.CacheDisabled {
_, _, err = d.Download(opts)
return err
}
var t Type
cacheDir, ok := dlcache.Get(ctx, opts.URL)
if ok {
var updated bool
if d, ok := d.(UpdatingDownloader); ok {
log.Info("Source can be updated, updating if required").Str("source", opts.Name).Str("downloader", d.Name()).Send()
updated, err = d.Update(Options{
Hash: opts.Hash,
HashAlgorithm: opts.HashAlgorithm,
Name: opts.Name,
URL: opts.URL,
Destination: cacheDir,
Progress: opts.Progress,
LocalDir: opts.LocalDir,
})
if err != nil {
return err
}
}
m, err := getManifest(cacheDir)
if err == nil {
t = m.Type
dest := filepath.Join(opts.Destination, m.Name)
ok, err := handleCache(cacheDir, dest, m.Name, t)
if err != nil {
return err
}
if ok && !updated {
log.Info("Source found in cache and linked to destination").Str("source", opts.Name).Stringer("type", t).Send()
return nil
} else if ok {
log.Info("Source updated and linked to destination").Str("source", opts.Name).Stringer("type", t).Send()
return nil
}
} else {
// If we cannot read the manifest,
// this cache entry is invalid and
// the source must be re-downloaded.
err = os.RemoveAll(cacheDir)
if err != nil {
return err
}
}
}
log.Info("Downloading source").Str("source", opts.Name).Str("downloader", d.Name()).Send()
cacheDir, err = dlcache.New(ctx, opts.URL)
if err != nil {
return err
}
t, name, err := d.Download(Options{
Hash: opts.Hash,
HashAlgorithm: opts.HashAlgorithm,
Name: opts.Name,
URL: opts.URL,
Destination: cacheDir,
Progress: opts.Progress,
LocalDir: opts.LocalDir,
})
if err != nil {
return err
}
err = writeManifest(cacheDir, Manifest{t, name})
if err != nil {
return err
}
dest := filepath.Join(opts.Destination, name)
_, err = handleCache(cacheDir, dest, name, t)
return err
}
// writeManifest writes the manifest to the specified cache directory.
func writeManifest(cacheDir string, m Manifest) error {
fl, err := os.Create(filepath.Join(cacheDir, manifestFileName))
if err != nil {
return err
}
defer fl.Close()
return msgpack.NewEncoder(fl).Encode(m)
}
// getManifest reads the manifest from the specified cache directory.
func getManifest(cacheDir string) (m Manifest, err error) {
fl, err := os.Open(filepath.Join(cacheDir, manifestFileName))
if err != nil {
return Manifest{}, err
}
defer fl.Close()
err = msgpack.NewDecoder(fl).Decode(&m)
return
}
// handleCache links the cache directory or a file within it to the destination
func handleCache(cacheDir, dest, name string, t Type) (bool, error) {
switch t {
case TypeFile:
cd, err := os.Open(cacheDir)
if err != nil {
return false, err
}
names, err := cd.Readdirnames(0)
if err == io.EOF {
break
} else if err != nil {
return false, err
}
cd.Close()
if slices.Contains(names, name) {
err = os.Link(filepath.Join(cacheDir, name), dest)
if err != nil {
return false, err
}
return true, nil
}
case TypeDir:
err := linkDir(cacheDir, dest)
if err != nil {
return false, err
}
return true, nil
}
return false, nil
}
// linkDir recursively walks through a directory, creating
// hard links for each file from the src directory to the
// dest directory. If it encounters a directory, it will
// create a directory with the same name and permissions
// in the dest directory, because hard links cannot be
// created for directories.
func linkDir(src, dest string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.Name() == manifestFileName {
return nil
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
newPath := filepath.Join(dest, rel)
if info.IsDir() {
return os.MkdirAll(newPath, info.Mode())
}
return os.Link(path, newPath)
})
}
func getDownloader(u string) Downloader {
for _, d := range Downloaders {
if d.MatchURL(u) {
return d
}
}
return nil
}
// normalizeURL normalizes a URL string, so that insignificant
// differences don't change the hash.
func normalizeURL(u string) (string, error) {
const normalizationFlags = purell.FlagRemoveTrailingSlash |
purell.FlagRemoveDefaultPort |
purell.FlagLowercaseHost |
purell.FlagLowercaseScheme |
purell.FlagRemoveDuplicateSlashes |
purell.FlagRemoveFragment |
purell.FlagRemoveUnnecessaryHostDots |
purell.FlagSortQuery |
purell.FlagDecodeHexHost |
purell.FlagDecodeOctalHost |
purell.FlagDecodeUnnecessaryEscapes |
purell.FlagRemoveEmptyPortSeparator
u, err := purell.NormalizeURLString(u, normalizationFlags)
if err != nil {
return "", err
}
// Fix magnet URLs after normalization
u = strings.Replace(u, "magnet://", "magnet:", 1)
return u, nil
}

264
internal/dl/file.go Normal file
View File

@ -0,0 +1,264 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package dl
import (
"bytes"
"context"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
"github.com/mholt/archiver/v4"
"github.com/schollz/progressbar/v3"
"lure.sh/lure/internal/shutils/handlers"
)
// FileDownloader downloads files using HTTP
type FileDownloader struct{}
// Name always returns "file"
func (FileDownloader) Name() string {
return "file"
}
// MatchURL always returns true, as FileDownloader
// is used as a fallback if nothing else matches
func (FileDownloader) MatchURL(string) bool {
return true
}
// Download downloads a file using HTTP. If the file is
// compressed using a supported format, it will be extracted
func (FileDownloader) Download(opts Options) (Type, string, error) {
u, err := url.Parse(opts.URL)
if err != nil {
return 0, "", err
}
query := u.Query()
name := query.Get("~name")
query.Del("~name")
archive := query.Get("~archive")
query.Del("~archive")
u.RawQuery = query.Encode()
var r io.ReadCloser
var size int64
if u.Scheme == "local" {
localFl, err := os.Open(filepath.Join(opts.LocalDir, u.Path))
if err != nil {
return 0, "", err
}
fi, err := localFl.Stat()
if err != nil {
return 0, "", err
}
size = fi.Size()
if name == "" {
name = fi.Name()
}
r = localFl
} else {
res, err := http.Get(u.String())
if err != nil {
return 0, "", err
}
size = res.ContentLength
if name == "" {
name = getFilename(res)
}
r = res.Body
}
defer r.Close()
opts.PostprocDisabled = archive == "false"
path := filepath.Join(opts.Destination, name)
fl, err := os.Create(path)
if err != nil {
return 0, "", err
}
defer fl.Close()
var bar io.WriteCloser
if opts.Progress != nil {
bar = progressbar.NewOptions64(
size,
progressbar.OptionSetDescription(name),
progressbar.OptionSetWriter(opts.Progress),
progressbar.OptionShowBytes(true),
progressbar.OptionSetWidth(10),
progressbar.OptionThrottle(65*time.Millisecond),
progressbar.OptionShowCount(),
progressbar.OptionOnCompletion(func() {
_, _ = io.WriteString(opts.Progress, "\n")
}),
progressbar.OptionSpinnerType(14),
progressbar.OptionFullWidth(),
progressbar.OptionSetRenderBlankState(true),
)
defer bar.Close()
} else {
bar = handlers.NopRWC{}
}
h, err := opts.NewHash()
if err != nil {
return 0, "", err
}
var w io.Writer
if opts.Hash != nil {
w = io.MultiWriter(fl, h, bar)
} else {
w = io.MultiWriter(fl, bar)
}
_, err = io.Copy(w, r)
if err != nil {
return 0, "", err
}
r.Close()
if opts.Hash != nil {
sum := h.Sum(nil)
if !bytes.Equal(sum, opts.Hash) {
return 0, "", ErrChecksumMismatch
}
}
if opts.PostprocDisabled {
return TypeFile, name, nil
}
_, err = fl.Seek(0, io.SeekStart)
if err != nil {
return 0, "", err
}
format, ar, err := archiver.Identify(name, fl)
if err == archiver.ErrNoMatch {
return TypeFile, name, nil
} else if err != nil {
return 0, "", err
}
err = extractFile(ar, format, name, opts)
if err != nil {
return 0, "", err
}
err = os.Remove(path)
return TypeDir, "", err
}
// extractFile extracts an archive or decompresses a file
func extractFile(r io.Reader, format archiver.Format, name string, opts Options) (err error) {
fname := format.Name()
switch format := format.(type) {
case archiver.Extractor:
err = format.Extract(context.Background(), r, nil, func(ctx context.Context, f archiver.File) error {
fr, err := f.Open()
if err != nil {
return err
}
defer fr.Close()
fi, err := f.Stat()
if err != nil {
return err
}
fm := fi.Mode()
path := filepath.Join(opts.Destination, f.NameInArchive)
err = os.MkdirAll(filepath.Dir(path), 0o755)
if err != nil {
return err
}
if f.IsDir() {
err = os.Mkdir(path, 0o755)
if err != nil {
return err
}
} else {
outFl, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fm.Perm())
if err != nil {
return err
}
defer outFl.Close()
_, err = io.Copy(outFl, fr)
return err
}
return nil
})
if err != nil {
return err
}
case archiver.Decompressor:
rc, err := format.OpenReader(r)
if err != nil {
return err
}
defer rc.Close()
path := filepath.Join(opts.Destination, name)
path = strings.TrimSuffix(path, fname)
outFl, err := os.Create(path)
if err != nil {
return err
}
_, err = io.Copy(outFl, rc)
if err != nil {
return err
}
}
return nil
}
// getFilename attempts to parse the Content-Disposition
// HTTP response header and extract a filename. If the
// header does not exist, it will use the last element
// of the path.
func getFilename(res *http.Response) (name string) {
_, params, err := mime.ParseMediaType(res.Header.Get("Content-Disposition"))
if err != nil {
return path.Base(res.Request.URL.Path)
}
if filename, ok := params["filename"]; ok {
return filename
} else {
return path.Base(res.Request.URL.Path)
}
}

198
internal/dl/git.go Normal file
View File

@ -0,0 +1,198 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package dl
import (
"errors"
"net/url"
"path"
"strconv"
"strings"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
)
// GitDownloader downloads Git repositories
type GitDownloader struct{}
// Name always returns "git"
func (GitDownloader) Name() string {
return "git"
}
// MatchURL matches any URLs that start with "git+"
func (GitDownloader) MatchURL(u string) bool {
return strings.HasPrefix(u, "git+")
}
// Download uses git to clone the repository from the specified URL.
// It allows specifying the revision, depth and recursion options
// via query string
func (GitDownloader) Download(opts Options) (Type, string, error) {
u, err := url.Parse(opts.URL)
if err != nil {
return 0, "", err
}
u.Scheme = strings.TrimPrefix(u.Scheme, "git+")
query := u.Query()
rev := query.Get("~rev")
query.Del("~rev")
name := query.Get("~name")
query.Del("~name")
depthStr := query.Get("~depth")
query.Del("~depth")
recursive := query.Get("~recursive")
query.Del("~recursive")
u.RawQuery = query.Encode()
depth := 0
if depthStr != "" {
depth, err = strconv.Atoi(depthStr)
if err != nil {
return 0, "", err
}
}
co := &git.CloneOptions{
URL: u.String(),
Depth: depth,
Progress: opts.Progress,
RecurseSubmodules: git.NoRecurseSubmodules,
}
if recursive == "true" {
co.RecurseSubmodules = git.DefaultSubmoduleRecursionDepth
}
r, err := git.PlainClone(opts.Destination, false, co)
if err != nil {
return 0, "", err
}
err = r.Fetch(&git.FetchOptions{
RefSpecs: []config.RefSpec{"+refs/*:refs/*"},
})
if err != git.NoErrAlreadyUpToDate && err != nil {
return 0, "", err
}
if rev != "" {
h, err := r.ResolveRevision(plumbing.Revision(rev))
if err != nil {
return 0, "", err
}
w, err := r.Worktree()
if err != nil {
return 0, "", err
}
err = w.Checkout(&git.CheckoutOptions{
Hash: *h,
})
if err != nil {
return 0, "", err
}
}
if name == "" {
name = strings.TrimSuffix(path.Base(u.Path), ".git")
}
return TypeDir, name, nil
}
// Update uses git to pull the repository and update it
// to the latest revision. It allows specifying the depth
// and recursion options via query string. It returns
// true if update was successful and false if the
// repository is already up-to-date
func (GitDownloader) Update(opts Options) (bool, error) {
u, err := url.Parse(opts.URL)
if err != nil {
return false, err
}
u.Scheme = strings.TrimPrefix(u.Scheme, "git+")
query := u.Query()
query.Del("~rev")
depthStr := query.Get("~depth")
query.Del("~depth")
recursive := query.Get("~recursive")
query.Del("~recursive")
u.RawQuery = query.Encode()
r, err := git.PlainOpen(opts.Destination)
if err != nil {
return false, err
}
w, err := r.Worktree()
if err != nil {
return false, err
}
depth := 0
if depthStr != "" {
depth, err = strconv.Atoi(depthStr)
if err != nil {
return false, err
}
}
po := &git.PullOptions{
Depth: depth,
Progress: opts.Progress,
RecurseSubmodules: git.NoRecurseSubmodules,
}
if recursive == "true" {
po.RecurseSubmodules = git.DefaultSubmoduleRecursionDepth
}
m, err := getManifest(opts.Destination)
manifestOK := err == nil
err = w.Pull(po)
if errors.Is(err, git.NoErrAlreadyUpToDate) {
return false, nil
} else if err != nil {
return false, err
}
if manifestOK {
err = writeManifest(opts.Destination, m)
if err != nil {
return true, err
}
}
return true, nil
}

90
internal/dl/torrent.go Normal file
View File

@ -0,0 +1,90 @@
package dl
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
var (
urlMatchRegex = regexp.MustCompile(`(magnet|torrent\+https?):.*`)
ErrAria2NotFound = errors.New("aria2 must be installed for torrent functionality")
ErrDestinationEmpty = errors.New("the destination directory is empty")
)
type TorrentDownloader struct{}
// Name always returns "file"
func (TorrentDownloader) Name() string {
return "torrent"
}
// MatchURL returns true if the URL is a magnet link
// or an http(s) link with a "torrent+" prefix
func (TorrentDownloader) MatchURL(u string) bool {
return urlMatchRegex.MatchString(u)
}
// Download downloads a file over the BitTorrent protocol.
func (TorrentDownloader) Download(opts Options) (Type, string, error) {
aria2Path, err := exec.LookPath("aria2c")
if err != nil {
return 0, "", ErrAria2NotFound
}
opts.URL = strings.TrimPrefix(opts.URL, "torrent+")
cmd := exec.Command(aria2Path, "--summary-interval=0", "--log-level=warn", "--seed-time=0", "--dir="+opts.Destination, opts.URL)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
return 0, "", fmt.Errorf("aria2c returned an error: %w", err)
}
err = removeTorrentFiles(opts.Destination)
if err != nil {
return 0, "", err
}
return determineType(opts.Destination)
}
func removeTorrentFiles(path string) error {
filePaths, err := filepath.Glob(filepath.Join(path, "*.torrent"))
if err != nil {
return err
}
for _, filePath := range filePaths {
err = os.Remove(filePath)
if err != nil {
return err
}
}
return nil
}
func determineType(path string) (Type, string, error) {
files, err := os.ReadDir(path)
if err != nil {
return 0, "", err
}
if len(files) > 1 {
return TypeDir, "", nil
} else if len(files) == 1 {
if files[0].IsDir() {
return TypeDir, files[0].Name(), nil
} else {
return TypeFile, files[0].Name(), nil
}
}
return 0, "", ErrDestinationEmpty
}

View File

@ -0,0 +1,93 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package dlcache
import (
"context"
"crypto/sha1"
"encoding/hex"
"io"
"os"
"path/filepath"
"lure.sh/lure/internal/config"
)
// BasePath returns the base path of the download cache
func BasePath(ctx context.Context) string {
return filepath.Join(config.GetPaths(ctx).CacheDir, "dl")
}
// New creates a new directory with the given ID in the cache.
// If a directory with the same ID already exists,
// it will be deleted before creating a new one.
func New(ctx context.Context, id string) (string, error) {
h, err := hashID(id)
if err != nil {
return "", err
}
itemPath := filepath.Join(BasePath(ctx), h)
fi, err := os.Stat(itemPath)
if err == nil || (fi != nil && !fi.IsDir()) {
err = os.RemoveAll(itemPath)
if err != nil {
return "", err
}
}
err = os.MkdirAll(itemPath, 0o755)
if err != nil {
return "", err
}
return itemPath, nil
}
// Get checks if an entry with the given ID
// already exists in the cache, and if so,
// returns the directory and true. If it
// does not exist, it returns an empty string
// and false.
func Get(ctx context.Context, id string) (string, bool) {
h, err := hashID(id)
if err != nil {
return "", false
}
itemPath := filepath.Join(BasePath(ctx), h)
_, err = os.Stat(itemPath)
if err != nil {
return "", false
}
return itemPath, true
}
// hashID hashes the input ID with SHA1
// and returns the hex string of the hashed
// ID.
func hashID(id string) (string, error) {
h := sha1.New()
_, err := io.WriteString(h, id)
if err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}

View File

@ -0,0 +1,76 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package dlcache_test
import (
"context"
"crypto/sha1"
"encoding/hex"
"io"
"os"
"path/filepath"
"testing"
"lure.sh/lure/internal/config"
"lure.sh/lure/internal/dlcache"
)
func init() {
dir, err := os.MkdirTemp("/tmp", "lure-dlcache-test.*")
if err != nil {
panic(err)
}
config.GetPaths(context.Background()).RepoDir = dir
}
func TestNew(t *testing.T) {
const id = "https://example.com"
dir, err := dlcache.New(id)
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
exp := filepath.Join(dlcache.BasePath(), sha1sum(id))
if dir != exp {
t.Errorf("Expected %s, got %s", exp, dir)
}
fi, err := os.Stat(dir)
if err != nil {
t.Errorf("stat: expected no error, got %s", err)
}
if !fi.IsDir() {
t.Errorf("Expected cache item to be a directory")
}
dir2, ok := dlcache.Get(id)
if !ok {
t.Errorf("Expected Get() to return valid value")
}
if dir2 != dir {
t.Errorf("Expected %s from Get(), got %s", dir, dir2)
}
}
func sha1sum(id string) string {
h := sha1.New()
_, _ = io.WriteString(h, id)
return hex.EncodeToString(h.Sum(nil))
}

110
internal/osutils/move.go Normal file
View File

@ -0,0 +1,110 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package osutils
import (
"io"
"os"
"path/filepath"
)
// Move attempts to use os.Rename and if that fails (such as for a cross-device move),
// it instead copies the source to the destination and then removes the source.
func Move(sourcePath, destPath string) error {
// Try to rename the source to the destination
err := os.Rename(sourcePath, destPath)
if err == nil {
return nil // Successful move
}
// Rename failed, so copy the source to the destination
err = copyDirOrFile(sourcePath, destPath)
if err != nil {
return err
}
// Copy successful, remove the original source
err = os.RemoveAll(sourcePath)
if err != nil {
return err
}
return nil
}
func copyDirOrFile(sourcePath, destPath string) error {
sourceInfo, err := os.Stat(sourcePath)
if err != nil {
return err
}
if sourceInfo.IsDir() {
return copyDir(sourcePath, destPath, sourceInfo)
} else if sourceInfo.Mode().IsRegular() {
return copyFile(sourcePath, destPath, sourceInfo)
} else {
// ignore non-regular files
return nil
}
}
func copyDir(sourcePath, destPath string, sourceInfo os.FileInfo) error {
err := os.MkdirAll(destPath, sourceInfo.Mode())
if err != nil {
return err
}
entries, err := os.ReadDir(sourcePath)
if err != nil {
return err
}
for _, entry := range entries {
sourceEntry := filepath.Join(sourcePath, entry.Name())
destEntry := filepath.Join(destPath, entry.Name())
err = copyDirOrFile(sourceEntry, destEntry)
if err != nil {
return err
}
}
return nil
}
func copyFile(sourcePath, destPath string, sourceInfo os.FileInfo) error {
sourceFile, err := os.Open(sourcePath)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_TRUNC|os.O_RDWR, sourceInfo.Mode())
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,223 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package overrides
import (
"reflect"
"strings"
"lure.sh/lure/internal/cpu"
"lure.sh/lure/internal/db"
"lure.sh/lure/pkg/distro"
"golang.org/x/exp/slices"
"golang.org/x/text/language"
)
type Opts struct {
Name string
Overrides bool
LikeDistros bool
Languages []string
LanguageTags []language.Tag
}
var DefaultOpts = &Opts{
Overrides: true,
LikeDistros: true,
Languages: []string{"en"},
}
// Resolve generates a slice of possible override names in the order that they should be checked
func Resolve(info *distro.OSRelease, opts *Opts) ([]string, error) {
if opts == nil {
opts = DefaultOpts
}
if !opts.Overrides {
return []string{opts.Name}, nil
}
langs, err := parseLangs(opts.Languages, opts.LanguageTags)
if err != nil {
return nil, err
}
architectures, err := cpu.CompatibleArches(cpu.Arch())
if err != nil {
return nil, err
}
distros := []string{info.ID}
if opts.LikeDistros {
distros = append(distros, info.Like...)
}
var out []string
for _, lang := range langs {
for _, distro := range distros {
for _, arch := range architectures {
out = append(out, opts.Name+"_"+arch+"_"+distro+"_"+lang)
}
out = append(out, opts.Name+"_"+distro+"_"+lang)
}
for _, arch := range architectures {
out = append(out, opts.Name+"_"+arch+"_"+lang)
}
out = append(out, opts.Name+"_"+lang)
}
for _, distro := range distros {
for _, arch := range architectures {
out = append(out, opts.Name+"_"+arch+"_"+distro)
}
out = append(out, opts.Name+"_"+distro)
}
for _, arch := range architectures {
out = append(out, opts.Name+"_"+arch)
}
out = append(out, opts.Name)
for index, item := range out {
out[index] = strings.TrimPrefix(strings.ReplaceAll(item, "-", "_"), "_")
}
return out, nil
}
func (o *Opts) WithName(name string) *Opts {
out := &Opts{}
*out = *o
out.Name = name
return out
}
func (o *Opts) WithOverrides(v bool) *Opts {
out := &Opts{}
*out = *o
out.Overrides = v
return out
}
func (o *Opts) WithLikeDistros(v bool) *Opts {
out := &Opts{}
*out = *o
out.LikeDistros = v
return out
}
func (o *Opts) WithLanguages(langs []string) *Opts {
out := &Opts{}
*out = *o
out.Languages = langs
return out
}
func (o *Opts) WithLanguageTags(langs []string) *Opts {
out := &Opts{}
*out = *o
out.Languages = langs
return out
}
// ResolvedPackage is a LURE package after its overrides
// have been resolved
type ResolvedPackage struct {
Name string `sh:"name"`
Version string `sh:"version"`
Release int `sh:"release"`
Epoch uint `sh:"epoch"`
Description string `db:"description"`
Homepage string `db:"homepage"`
Maintainer string `db:"maintainer"`
Architectures []string `sh:"architectures"`
Licenses []string `sh:"license"`
Provides []string `sh:"provides"`
Conflicts []string `sh:"conflicts"`
Replaces []string `sh:"replaces"`
Depends []string `sh:"deps"`
BuildDepends []string `sh:"build_deps"`
OptDepends []string `sh:"opt_deps"`
}
func ResolvePackage(pkg *db.Package, overrides []string) *ResolvedPackage {
out := &ResolvedPackage{}
outVal := reflect.ValueOf(out).Elem()
pkgVal := reflect.ValueOf(pkg).Elem()
for i := 0; i < outVal.NumField(); i++ {
fieldVal := outVal.Field(i)
fieldType := fieldVal.Type()
pkgFieldVal := pkgVal.FieldByName(outVal.Type().Field(i).Name)
pkgFieldType := pkgFieldVal.Type()
if strings.HasPrefix(pkgFieldType.String(), "db.JSON") {
pkgFieldVal = pkgFieldVal.FieldByName("Val")
pkgFieldType = pkgFieldVal.Type()
}
if pkgFieldType.AssignableTo(fieldType) {
fieldVal.Set(pkgFieldVal)
continue
}
if pkgFieldVal.Kind() == reflect.Map && pkgFieldType.Elem().AssignableTo(fieldType) {
for _, override := range overrides {
overrideVal := pkgFieldVal.MapIndex(reflect.ValueOf(override))
if !overrideVal.IsValid() {
continue
}
fieldVal.Set(overrideVal)
break
}
}
}
return out
}
func parseLangs(langs []string, tags []language.Tag) ([]string, error) {
out := make([]string, len(tags)+len(langs))
for i, tag := range tags {
base, _ := tag.Base()
out[i] = base.String()
}
for i, lang := range langs {
tag, err := language.Parse(lang)
if err != nil {
return nil, err
}
base, _ := tag.Base()
out[len(tags)+i] = base.String()
}
slices.Sort(out)
out = slices.Compact(out)
return out, nil
}

View File

@ -0,0 +1,195 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package overrides_test
import (
"os"
"reflect"
"testing"
"lure.sh/lure/internal/overrides"
"lure.sh/lure/pkg/distro"
"golang.org/x/text/language"
)
var info = &distro.OSRelease{
ID: "centos",
Like: []string{"rhel", "fedora"},
}
func TestResolve(t *testing.T) {
names, err := overrides.Resolve(info, nil)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
expected := []string{
"amd64_centos_en",
"centos_en",
"amd64_rhel_en",
"rhel_en",
"amd64_fedora_en",
"fedora_en",
"amd64_en",
"en",
"amd64_centos",
"centos",
"amd64_rhel",
"rhel",
"amd64_fedora",
"fedora",
"amd64",
"",
}
if !reflect.DeepEqual(names, expected) {
t.Errorf("expected %v, got %v", expected, names)
}
}
func TestResolveName(t *testing.T) {
names, err := overrides.Resolve(info, &overrides.Opts{
Name: "deps",
Overrides: true,
LikeDistros: true,
})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
expected := []string{
"deps_amd64_centos",
"deps_centos",
"deps_amd64_rhel",
"deps_rhel",
"deps_amd64_fedora",
"deps_fedora",
"deps_amd64",
"deps",
}
if !reflect.DeepEqual(names, expected) {
t.Errorf("expected %v, got %v", expected, names)
}
}
func TestResolveArch(t *testing.T) {
os.Setenv("LURE_ARCH", "arm7")
defer os.Setenv("LURE_ARCH", "")
names, err := overrides.Resolve(info, &overrides.Opts{
Name: "deps",
Overrides: true,
LikeDistros: true,
})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
expected := []string{
"deps_arm7_centos",
"deps_arm6_centos",
"deps_arm5_centos",
"deps_centos",
"deps_arm7_rhel",
"deps_arm6_rhel",
"deps_arm5_rhel",
"deps_rhel",
"deps_arm7_fedora",
"deps_arm6_fedora",
"deps_arm5_fedora",
"deps_fedora",
"deps_arm7",
"deps_arm6",
"deps_arm5",
"deps",
}
if !reflect.DeepEqual(names, expected) {
t.Errorf("expected %v, got %v", expected, names)
}
}
func TestResolveNoLikeDistros(t *testing.T) {
names, err := overrides.Resolve(info, &overrides.Opts{
Overrides: true,
LikeDistros: false,
})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
expected := []string{
"amd64_centos",
"centos",
"amd64",
"",
}
if !reflect.DeepEqual(names, expected) {
t.Errorf("expected %v, got %v", expected, names)
}
}
func TestResolveNoOverrides(t *testing.T) {
names, err := overrides.Resolve(info, &overrides.Opts{
Name: "deps",
Overrides: false,
LikeDistros: false,
})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
expected := []string{"deps"}
if !reflect.DeepEqual(names, expected) {
t.Errorf("expected %v, got %v", expected, names)
}
}
func TestResolveLangs(t *testing.T) {
names, err := overrides.Resolve(info, &overrides.Opts{
Overrides: true,
Languages: []string{"ru_RU", "en", "en_US"},
LanguageTags: []language.Tag{language.BritishEnglish},
})
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
expected := []string{
"amd64_centos_en",
"centos_en",
"amd64_en",
"en",
"amd64_centos_ru",
"centos_ru",
"amd64_ru",
"ru",
"amd64_centos",
"centos",
"amd64",
"",
}
if !reflect.DeepEqual(names, expected) {
t.Errorf("expected %v, got %v", expected, names)
}
}

View File

@ -0,0 +1,37 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package pager
import (
"bytes"
"io"
"github.com/alecthomas/chroma/v2/quick"
)
func SyntaxHighlightBash(r io.Reader, style string) (string, error) {
data, err := io.ReadAll(r)
if err != nil {
return "", err
}
w := &bytes.Buffer{}
err = quick.Highlight(w, string(data), "bash", "terminal", style)
return w.String(), err
}

143
internal/pager/pager.go Normal file
View File

@ -0,0 +1,143 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package pager
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/reflow/wordwrap"
)
var (
titleStyle lipgloss.Style
infoStyle lipgloss.Style
)
func init() {
b1 := lipgloss.RoundedBorder()
b1.Right = "\u251C"
titleStyle = lipgloss.NewStyle().BorderStyle(b1).Padding(0, 1)
b2 := lipgloss.RoundedBorder()
b2.Left = "\u2524"
infoStyle = titleStyle.Copy().BorderStyle(b2)
}
type Pager struct {
model pagerModel
}
func New(name, content string) *Pager {
return &Pager{
model: pagerModel{
name: name,
content: content,
},
}
}
func (p *Pager) Run() error {
prog := tea.NewProgram(
p.model,
tea.WithMouseCellMotion(),
tea.WithAltScreen(),
)
_, err := prog.Run()
return err
}
type pagerModel struct {
name string
content string
ready bool
viewport viewport.Model
}
func (pm pagerModel) Init() tea.Cmd {
return nil
}
func (pm pagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var (
cmd tea.Cmd
cmds []tea.Cmd
)
switch msg := msg.(type) {
case tea.KeyMsg:
k := msg.String()
if k == "ctrl+c" || k == "q" || k == "esc" {
return pm, tea.Quit
}
case tea.WindowSizeMsg:
headerHeight := lipgloss.Height(pm.headerView())
footerHeight := lipgloss.Height(pm.footerView())
verticalMarginHeight := headerHeight + footerHeight
if !pm.ready {
pm.viewport = viewport.New(msg.Width, msg.Height-verticalMarginHeight)
pm.viewport.HighPerformanceRendering = true
pm.viewport.YPosition = headerHeight + 1
pm.viewport.SetContent(wordwrap.String(pm.content, msg.Width))
pm.ready = true
} else {
pm.viewport.Width = msg.Width
pm.viewport.Height = msg.Height - verticalMarginHeight
}
cmds = append(cmds, viewport.Sync(pm.viewport))
}
// Handle keyboard and mouse events in the viewport
pm.viewport, cmd = pm.viewport.Update(msg)
cmds = append(cmds, cmd)
return pm, tea.Batch(cmds...)
}
func (pm pagerModel) View() string {
if !pm.ready {
return "\n Initializing..."
}
return fmt.Sprintf("%s\n%s\n%s", pm.headerView(), pm.viewport.View(), pm.footerView())
}
func (pm pagerModel) headerView() string {
title := titleStyle.Render(pm.name)
line := strings.Repeat("─", max(0, pm.viewport.Width-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (pm pagerModel) footerView() string {
info := infoStyle.Render(fmt.Sprintf("%3.f%%", pm.viewport.ScrollPercent()*100))
line := strings.Repeat("─", max(0, pm.viewport.Width-lipgloss.Width(info)))
return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}

View File

@ -0,0 +1,223 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package decoder
import (
"context"
"errors"
"reflect"
"strings"
"github.com/mitchellh/mapstructure"
"lure.sh/lure/internal/overrides"
"lure.sh/lure/pkg/distro"
"golang.org/x/exp/slices"
"mvdan.cc/sh/v3/expand"
"mvdan.cc/sh/v3/interp"
"mvdan.cc/sh/v3/syntax"
)
var ErrNotPointerToStruct = errors.New("val must be a pointer to a struct")
type VarNotFoundError struct {
name string
}
func (nfe VarNotFoundError) Error() string {
return "required variable '" + nfe.name + "' could not be found"
}
type InvalidTypeError struct {
name string
vartype string
exptype string
}
func (ite InvalidTypeError) Error() string {
return "variable '" + ite.name + "' is of type " + ite.vartype + ", but " + ite.exptype + " is expected"
}
// Decoder provides methods for decoding variable values
type Decoder struct {
info *distro.OSRelease
Runner *interp.Runner
// Enable distro overrides (true by default)
Overrides bool
// Enable using like distros for overrides
LikeDistros bool
}
// New creates a new variable decoder
func New(info *distro.OSRelease, runner *interp.Runner) *Decoder {
return &Decoder{info, runner, true, len(info.Like) > 0}
}
// DecodeVar decodes a variable to val using reflection.
// Structs should use the "sh" struct tag.
func (d *Decoder) DecodeVar(name string, val any) error {
variable := d.getVar(name)
if variable == nil {
return VarNotFoundError{name}
}
dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
WeaklyTypedInput: true,
DecodeHook: mapstructure.DecodeHookFuncValue(func(from, to reflect.Value) (interface{}, error) {
if strings.Contains(to.Type().String(), "db.JSON") {
valType := to.FieldByName("Val").Type()
if !from.Type().AssignableTo(valType) {
return nil, InvalidTypeError{name, from.Type().String(), valType.String()}
}
to.FieldByName("Val").Set(from)
return to, nil
}
return from.Interface(), nil
}),
Result: val,
TagName: "sh",
})
if err != nil {
return err
}
switch variable.Kind {
case expand.Indexed:
return dec.Decode(variable.List)
case expand.Associative:
return dec.Decode(variable.Map)
default:
return dec.Decode(variable.Str)
}
}
// DecodeVars decodes all variables to val using reflection.
// Structs should use the "sh" struct tag.
func (d *Decoder) DecodeVars(val any) error {
valKind := reflect.TypeOf(val).Kind()
if valKind != reflect.Pointer {
return ErrNotPointerToStruct
} else {
elemKind := reflect.TypeOf(val).Elem().Kind()
if elemKind != reflect.Struct {
return ErrNotPointerToStruct
}
}
rVal := reflect.ValueOf(val).Elem()
for i := 0; i < rVal.NumField(); i++ {
field := rVal.Field(i)
fieldType := rVal.Type().Field(i)
if !fieldType.IsExported() {
continue
}
name := fieldType.Name
tag := fieldType.Tag.Get("sh")
required := false
if tag != "" {
if strings.Contains(tag, ",") {
splitTag := strings.Split(tag, ",")
name = splitTag[0]
if len(splitTag) > 1 {
if slices.Contains(splitTag, "required") {
required = true
}
}
} else {
name = tag
}
}
newVal := reflect.New(field.Type())
err := d.DecodeVar(name, newVal.Interface())
if _, ok := err.(VarNotFoundError); ok && !required {
continue
} else if err != nil {
return err
}
field.Set(newVal.Elem())
}
return nil
}
type ScriptFunc func(ctx context.Context, opts ...interp.RunnerOption) error
// GetFunc returns a function corresponding to a bash function
// with the given name
func (d *Decoder) GetFunc(name string) (ScriptFunc, bool) {
fn := d.getFunc(name)
if fn == nil {
return nil, false
}
return func(ctx context.Context, opts ...interp.RunnerOption) error {
sub := d.Runner.Subshell()
for _, opt := range opts {
opt(sub)
}
return sub.Run(ctx, fn)
}, true
}
func (d *Decoder) getFunc(name string) *syntax.Stmt {
names, err := overrides.Resolve(d.info, overrides.DefaultOpts.WithName(name))
if err != nil {
return nil
}
for _, fnName := range names {
fn, ok := d.Runner.Funcs[fnName]
if ok {
return fn
}
}
return nil
}
// getVar gets a variable based on its name, taking into account
// override variables and nameref variables.
func (d *Decoder) getVar(name string) *expand.Variable {
names, err := overrides.Resolve(d.info, overrides.DefaultOpts.WithName(name))
if err != nil {
return nil
}
for _, varName := range names {
val, ok := d.Runner.Vars[varName]
if ok {
// Resolve nameref variables
_, resolved := val.Resolve(expand.FuncEnviron(func(s string) string {
if val, ok := d.Runner.Vars[s]; ok {
return val.String()
}
return ""
}))
val = resolved
return &val
}
}
return nil
}

View File

@ -0,0 +1,224 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package decoder_test
import (
"bytes"
"context"
"errors"
"os"
"reflect"
"strings"
"testing"
"lure.sh/lure/internal/shutils/decoder"
"lure.sh/lure/pkg/distro"
"mvdan.cc/sh/v3/interp"
"mvdan.cc/sh/v3/syntax"
)
type BuildVars struct {
Name string `sh:"name,required"`
Version string `sh:"version,required"`
Release int `sh:"release,required"`
Epoch uint `sh:"epoch"`
Description string `sh:"desc"`
Homepage string `sh:"homepage"`
Maintainer string `sh:"maintainer"`
Architectures []string `sh:"architectures"`
Licenses []string `sh:"license"`
Provides []string `sh:"provides"`
Conflicts []string `sh:"conflicts"`
Depends []string `sh:"deps"`
BuildDepends []string `sh:"build_deps"`
Replaces []string `sh:"replaces"`
}
const testScript = `
name='test'
version='0.0.1'
release=1
epoch=2
desc="Test package"
homepage='https://lure.arsenm.dev'
maintainer='Arsen Musayelyan <arsen@arsenm.dev>'
architectures=('arm64' 'amd64')
license=('GPL-3.0-or-later')
provides=('test')
conflicts=('test')
replaces=('test-old')
replaces_test_os=('test-legacy')
deps=('sudo')
build_deps=('golang')
build_deps_arch=('go')
test() {
echo "Test"
}
package() {
install-binary test
}
`
var osRelease = &distro.OSRelease{
ID: "test_os",
Like: []string{"arch"},
}
func TestDecodeVars(t *testing.T) {
ctx := context.Background()
fl, err := syntax.NewParser().Parse(strings.NewReader(testScript), "lure.sh")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
runner, err := interp.New()
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = runner.Run(ctx, fl)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
dec := decoder.New(osRelease, runner)
var bv BuildVars
err = dec.DecodeVars(&bv)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
expected := BuildVars{
Name: "test",
Version: "0.0.1",
Release: 1,
Epoch: 2,
Description: "Test package",
Homepage: "https://lure.arsenm.dev",
Maintainer: "Arsen Musayelyan <arsen@arsenm.dev>",
Architectures: []string{"arm64", "amd64"},
Licenses: []string{"GPL-3.0-or-later"},
Provides: []string{"test"},
Conflicts: []string{"test"},
Replaces: []string{"test-legacy"},
Depends: []string{"sudo"},
BuildDepends: []string{"go"},
}
if !reflect.DeepEqual(bv, expected) {
t.Errorf("Expected %v, got %v", expected, bv)
}
}
func TestDecodeVarsMissing(t *testing.T) {
ctx := context.Background()
const testScript = `
name='test'
epoch=2
desc="Test package"
homepage='https://lure.arsenm.dev'
maintainer='Arsen Musayelyan <arsen@arsenm.dev>'
architectures=('arm64' 'amd64')
license=('GPL-3.0-or-later')
provides=('test')
conflicts=('test')
replaces=('test-old')
replaces_test_os=('test-legacy')
deps=('sudo')
build_deps=('golang')
build_deps_arch=('go')
test() {
echo "Test"
}
package() {
install-binary test
}
`
fl, err := syntax.NewParser().Parse(strings.NewReader(testScript), "lure.sh")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
runner, err := interp.New()
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = runner.Run(ctx, fl)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
dec := decoder.New(osRelease, runner)
var bv BuildVars
err = dec.DecodeVars(&bv)
var notFoundErr decoder.VarNotFoundError
if !errors.As(err, &notFoundErr) {
t.Fatalf("Expected VarNotFoundError, got %T %v", err, err)
}
}
func TestGetFunc(t *testing.T) {
ctx := context.Background()
fl, err := syntax.NewParser().Parse(strings.NewReader(testScript), "lure.sh")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
runner, err := interp.New()
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = runner.Run(ctx, fl)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
dec := decoder.New(osRelease, runner)
fn, ok := dec.GetFunc("test")
if !ok {
t.Fatalf("Expected test() function to exist")
}
buf := &bytes.Buffer{}
err = fn(ctx, interp.StdIO(os.Stdin, buf, buf))
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if buf.String() != "Test\n" {
t.Fatalf(`Expected "Test\n", got %#v`, buf.String())
}
}

View File

@ -0,0 +1,63 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package handlers
import (
"context"
"fmt"
"time"
"mvdan.cc/sh/v3/interp"
)
func InsufficientArgsError(cmd string, exp, got int) error {
argsWord := "arguments"
if exp == 1 {
argsWord = "argument"
}
return fmt.Errorf("%s: command requires at least %d %s, got %d", cmd, exp, argsWord, got)
}
type ExecFunc func(hc interp.HandlerContext, name string, args []string) error
type ExecFuncs map[string]ExecFunc
// ExecHandler returns a new ExecHandlerFunc that falls back to fallback
// if the command cannot be found in the map. If fallback is nil, the default
// handler is used.
func (ef ExecFuncs) ExecHandler(fallback interp.ExecHandlerFunc) interp.ExecHandlerFunc {
return func(ctx context.Context, args []string) error {
name := args[0]
if fn, ok := ef[name]; ok {
hctx := interp.HandlerCtx(ctx)
if len(args) > 1 {
return fn(hctx, args[0], args[1:])
} else {
return fn(hctx, args[0], nil)
}
}
if fallback == nil {
fallback = interp.DefaultExecHandler(2 * time.Second)
}
return fallback(ctx, args)
}
}

View File

@ -0,0 +1,124 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package handlers_test
import (
"context"
"strings"
"testing"
"lure.sh/lure/internal/shutils/handlers"
"lure.sh/lure/internal/shutils/decoder"
"lure.sh/lure/pkg/distro"
"mvdan.cc/sh/v3/interp"
"mvdan.cc/sh/v3/syntax"
)
const testScript = `
name='test'
version='0.0.1'
release=1
epoch=2
desc="Test package"
homepage='https://lure.sh'
maintainer='Elara Musayelyan <elara@elara.ws>'
architectures=('arm64' 'amd64')
license=('GPL-3.0-or-later')
provides=('test')
conflicts=('test')
replaces=('test-old')
replaces_test_os=('test-legacy')
deps=('sudo')
build_deps=('golang')
build_deps_arch=('go')
test() {
test-cmd "Hello, World"
test-fb
}
package() {
install-binary test
}
`
var osRelease = &distro.OSRelease{
ID: "test_os",
Like: []string{"arch"},
}
func TestExecFuncs(t *testing.T) {
ctx := context.Background()
fl, err := syntax.NewParser().Parse(strings.NewReader(testScript), "lure.sh")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
runner, err := interp.New()
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = runner.Run(ctx, fl)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
dec := decoder.New(osRelease, runner)
fn, ok := dec.GetFunc("test")
if !ok {
t.Fatalf("Expected test() function to exist")
}
eh := shutils.ExecFuncs{
"test-cmd": func(hc interp.HandlerContext, name string, args []string) error {
if name != "test-cmd" {
t.Errorf("Expected name to be 'test-cmd', got '%s'", name)
}
if len(args) < 1 {
t.Fatalf("Expected at least one argument, got %d", len(args))
}
if args[0] != "Hello, World" {
t.Errorf("Expected first argument to be 'Hello, World', got '%s'", args[0])
}
return nil
},
}
fbInvoked := false
fbHandler := func(context.Context, []string) error {
fbInvoked = true
return nil
}
err = fn(ctx, interp.ExecHandler(eh.ExecHandler(fbHandler)))
if err != nil {
t.Errorf("Expected no error, got %s", err)
}
if !fbInvoked {
t.Errorf("Expected fallback handler to be invoked")
}
}

View File

@ -0,0 +1,114 @@
package handlers
import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"time"
"lure.sh/fakeroot"
"mvdan.cc/sh/v3/expand"
"mvdan.cc/sh/v3/interp"
)
// FakerootExecHandler was extracted from github.com/mvdan/sh/interp/handler.go
// and modified to run commands in a fakeroot environent.
func FakerootExecHandler(killTimeout time.Duration) interp.ExecHandlerFunc {
return func(ctx context.Context, args []string) error {
hc := interp.HandlerCtx(ctx)
path, err := interp.LookPathDir(hc.Dir, hc.Env, args[0])
if err != nil {
fmt.Fprintln(hc.Stderr, err)
return interp.NewExitStatus(127)
}
cmd := &exec.Cmd{
Path: path,
Args: args,
Env: execEnv(hc.Env),
Dir: hc.Dir,
Stdin: hc.Stdin,
Stdout: hc.Stdout,
Stderr: hc.Stderr,
}
err = fakeroot.Apply(cmd)
if err != nil {
return err
}
err = cmd.Start()
if err == nil {
if done := ctx.Done(); done != nil {
go func() {
<-done
if killTimeout <= 0 || runtime.GOOS == "windows" {
_ = cmd.Process.Signal(os.Kill)
return
}
// TODO: don't temporarily leak this goroutine
// if the program stops itself with the
// interrupt.
go func() {
time.Sleep(killTimeout)
_ = cmd.Process.Signal(os.Kill)
}()
_ = cmd.Process.Signal(os.Interrupt)
}()
}
err = cmd.Wait()
}
switch x := err.(type) {
case *exec.ExitError:
// started, but errored - default to 1 if OS
// doesn't have exit statuses
if status, ok := x.Sys().(syscall.WaitStatus); ok {
if status.Signaled() {
if ctx.Err() != nil {
return ctx.Err()
}
return interp.NewExitStatus(uint8(128 + status.Signal()))
}
return interp.NewExitStatus(uint8(status.ExitStatus()))
}
return interp.NewExitStatus(1)
case *exec.Error:
// did not start
fmt.Fprintf(hc.Stderr, "%v\n", err)
return interp.NewExitStatus(127)
default:
return err
}
}
}
// execEnv was extracted from github.com/mvdan/sh/interp/vars.go
func execEnv(env expand.Environ) []string {
list := make([]string, 0, 64)
env.Each(func(name string, vr expand.Variable) bool {
if !vr.IsSet() {
// If a variable is set globally but unset in the
// runner, we need to ensure it's not part of the final
// list. Seems like zeroing the element is enough.
// This is a linear search, but this scenario should be
// rare, and the number of variables shouldn't be large.
for i, kv := range list {
if strings.HasPrefix(kv, name+"=") {
list[i] = ""
}
}
}
if vr.Exported && vr.Kind == expand.String {
list = append(list, name+"="+vr.String())
}
return true
})
return list
}

View File

@ -0,0 +1,55 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package handlers
import (
"context"
"io"
"os"
)
func NopReadDir(context.Context, string) ([]os.FileInfo, error) {
return nil, os.ErrNotExist
}
func NopStat(context.Context, string, bool) (os.FileInfo, error) {
return nil, os.ErrNotExist
}
func NopExec(context.Context, []string) error {
return nil
}
func NopOpen(context.Context, string, int, os.FileMode) (io.ReadWriteCloser, error) {
return NopRWC{}, nil
}
type NopRWC struct{}
func (NopRWC) Read([]byte) (int, error) {
return 0, io.EOF
}
func (NopRWC) Write(b []byte) (int, error) {
return len(b), nil
}
func (NopRWC) Close() error {
return nil
}

View File

@ -0,0 +1,58 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package handlers_test
import (
"bytes"
"context"
"os"
"strings"
"testing"
"lure.sh/lure/internal/shutils/handlers"
"mvdan.cc/sh/v3/interp"
"mvdan.cc/sh/v3/syntax"
)
func TestNopExec(t *testing.T) {
ctx := context.Background()
fl, err := syntax.NewParser().Parse(strings.NewReader(`/bin/echo test`), "lure.sh")
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
buf := &bytes.Buffer{}
runner, err := interp.New(
interp.ExecHandler(handlers.NopExec),
interp.StdIO(os.Stdin, buf, buf),
)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
err = runner.Run(ctx, fl)
if err != nil {
t.Fatalf("Expected no error, got %s", err)
}
if buf.String() != "" {
t.Fatalf("Expected empty string, got %#v", buf.String())
}
}

View File

@ -0,0 +1,80 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package handlers
import (
"context"
"io"
"io/fs"
"path/filepath"
"strings"
"time"
"golang.org/x/exp/slices"
"mvdan.cc/sh/v3/interp"
)
func RestrictedReadDir(allowedPrefixes ...string) interp.ReadDirHandlerFunc {
return func(ctx context.Context, s string) ([]fs.FileInfo, error) {
path := filepath.Clean(s)
for _, allowedPrefix := range allowedPrefixes {
if strings.HasPrefix(path, allowedPrefix) {
return interp.DefaultReadDirHandler()(ctx, s)
}
}
return nil, fs.ErrNotExist
}
}
func RestrictedStat(allowedPrefixes ...string) interp.StatHandlerFunc {
return func(ctx context.Context, s string, b bool) (fs.FileInfo, error) {
path := filepath.Clean(s)
for _, allowedPrefix := range allowedPrefixes {
if strings.HasPrefix(path, allowedPrefix) {
return interp.DefaultStatHandler()(ctx, s, b)
}
}
return nil, fs.ErrNotExist
}
}
func RestrictedOpen(allowedPrefixes ...string) interp.OpenHandlerFunc {
return func(ctx context.Context, s string, i int, fm fs.FileMode) (io.ReadWriteCloser, error) {
path := filepath.Clean(s)
for _, allowedPrefix := range allowedPrefixes {
if strings.HasPrefix(path, allowedPrefix) {
return interp.DefaultOpenHandler()(ctx, s, i, fm)
}
}
return NopRWC{}, nil
}
}
func RestrictedExec(allowedCmds ...string) interp.ExecHandlerFunc {
return func(ctx context.Context, args []string) error {
if slices.Contains(allowedCmds, args[0]) {
return interp.DefaultExecHandler(2*time.Second)(ctx, args)
}
return nil
}
}

View File

@ -0,0 +1,284 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package helpers
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"unsafe"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"golang.org/x/exp/slices"
"lure.sh/lure/internal/shutils/handlers"
"mvdan.cc/sh/v3/interp"
)
var (
ErrNoPipe = errors.New("command requires data to be piped in")
ErrNoDetectManNum = errors.New("manual number cannot be detected from the filename")
)
// Helpers contains all the helper commands
var Helpers = handlers.ExecFuncs{
"install-binary": installHelperCmd("/usr/bin", 0o755),
"install-systemd-user": installHelperCmd("/usr/lib/systemd/user", 0o644),
"install-systemd": installHelperCmd("/usr/lib/systemd/system", 0o644),
"install-config": installHelperCmd("/etc", 0o644),
"install-license": installHelperCmd("/usr/share/licenses", 0o644),
"install-desktop": installHelperCmd("/usr/share/applications", 0o644),
"install-icon": installHelperCmd("/usr/share/pixmaps", 0o644),
"install-manual": installManualCmd,
"install-completion": installCompletionCmd,
"install-library": installLibraryCmd,
"git-version": gitVersionCmd,
}
// Restricted contains restricted read-only helper commands
// that don't modify any state
var Restricted = handlers.ExecFuncs{
"git-version": gitVersionCmd,
}
func installHelperCmd(prefix string, perms os.FileMode) handlers.ExecFunc {
return func(hc interp.HandlerContext, cmd string, args []string) error {
if len(args) < 1 {
return handlers.InsufficientArgsError(cmd, 1, len(args))
}
from := resolvePath(hc, args[0])
to := ""
if len(args) > 1 {
to = filepath.Join(hc.Env.Get("pkgdir").Str, prefix, args[1])
} else {
to = filepath.Join(hc.Env.Get("pkgdir").Str, prefix, filepath.Base(from))
}
err := helperInstall(from, to, perms)
if err != nil {
return fmt.Errorf("%s: %w", cmd, err)
}
return nil
}
}
func installManualCmd(hc interp.HandlerContext, cmd string, args []string) error {
if len(args) < 1 {
return handlers.InsufficientArgsError(cmd, 1, len(args))
}
from := resolvePath(hc, args[0])
number := filepath.Base(from)
// The man page may be compressed with gzip.
// If it is, the .gz extension must be removed to properly
// detect the number at the end of the filename.
number = strings.TrimSuffix(number, ".gz")
number = strings.TrimPrefix(filepath.Ext(number), ".")
// If number is not actually a number, return an error
if _, err := strconv.Atoi(number); err != nil {
return fmt.Errorf("install-manual: %w", ErrNoDetectManNum)
}
prefix := "/usr/share/man/man" + number
to := filepath.Join(hc.Env.Get("pkgdir").Str, prefix, filepath.Base(from))
return helperInstall(from, to, 0o644)
}
func installCompletionCmd(hc interp.HandlerContext, cmd string, args []string) error {
// If the command's stdin is the same as the system's,
// that means nothing was piped in. In this case, return an error.
if hc.Stdin == os.Stdin {
return fmt.Errorf("install-completion: %w", ErrNoPipe)
}
if len(args) < 2 {
return handlers.InsufficientArgsError(cmd, 2, len(args))
}
shell := args[0]
name := args[1]
var prefix string
switch shell {
case "bash":
prefix = "/usr/share/bash-completion/completions"
case "zsh":
prefix = "/usr/share/zsh/site-functions"
name = "_" + name
case "fish":
prefix = "/usr/share/fish/vendor_completions.d"
name += ".fish"
}
path := filepath.Join(hc.Env.Get("pkgdir").Str, prefix, name)
err := os.MkdirAll(filepath.Dir(path), 0o755)
if err != nil {
return err
}
dst, err := os.OpenFile(path, os.O_TRUNC|os.O_CREATE|os.O_RDWR, 0o644)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, hc.Stdin)
return err
}
func installLibraryCmd(hc interp.HandlerContext, cmd string, args []string) error {
prefix := getLibPrefix(hc)
fn := installHelperCmd(prefix, 0o755)
return fn(hc, cmd, args)
}
// See https://wiki.debian.org/Multiarch/Tuples
var multiarchTupleMap = map[string]string{
"386": "i386-linux-gnu",
"amd64": "x86_64-linux-gnu",
"arm5": "arm-linux-gnueabi",
"arm6": "arm-linux-gnueabihf",
"arm7": "arm-linux-gnueabihf",
"arm64": "aarch64-linux-gnu",
"mips": "mips-linux-gnu",
"mipsle": "mipsel-linux-gnu",
"mips64": "mips64-linux-gnuabi64",
"mips64le": "mips64el-linux-gnuabi64",
"ppc64": "powerpc64-linux-gnu",
"ppc64le": "powerpc64le-linux-gnu",
"s390x": "s390x-linux-gnu",
"riscv64": "riscv64-linux-gnu",
"loong64": "loongarch64-linux-gnu",
}
// usrLibDistros is a list of distros that don't support
// /usr/lib64, and must use /usr/lib
var usrLibDistros = []string{
"arch",
"alpine",
"void",
"chimera",
}
// Based on CMake's GNUInstallDirs
func getLibPrefix(hc interp.HandlerContext) string {
if dir, ok := os.LookupEnv("LURE_LIB_DIR"); ok {
return dir
}
out := "/usr/lib"
distroID := hc.Env.Get("DISTRO_ID").Str
distroLike := strings.Split(hc.Env.Get("DISTRO_ID_LIKE").Str, " ")
for _, usrLibDistro := range usrLibDistros {
if distroID == usrLibDistro || slices.Contains(distroLike, usrLibDistro) {
return out
}
}
wordSize := unsafe.Sizeof(uintptr(0))
if wordSize == 8 {
out = "/usr/lib64"
}
architecture := hc.Env.Get("ARCH").Str
if distroID == "debian" || slices.Contains(distroLike, "debian") ||
distroID == "ubuntu" || slices.Contains(distroLike, "ubuntu") {
tuple, ok := multiarchTupleMap[architecture]
if ok {
out = filepath.Join("/usr/lib", tuple)
}
}
return out
}
func gitVersionCmd(hc interp.HandlerContext, cmd string, args []string) error {
path := hc.Dir
if len(args) > 0 {
path = resolvePath(hc, args[0])
}
r, err := git.PlainOpen(path)
if err != nil {
return fmt.Errorf("git-version: %w", err)
}
revNum := 0
commits, err := r.Log(&git.LogOptions{})
if err != nil {
return fmt.Errorf("git-version: %w", err)
}
commits.ForEach(func(*object.Commit) error {
revNum++
return nil
})
HEAD, err := r.Head()
if err != nil {
return fmt.Errorf("git-version: %w", err)
}
hash := HEAD.Hash().String()
fmt.Fprintf(hc.Stdout, "%d.%s\n", revNum, hash[:7])
return nil
}
func helperInstall(from, to string, perms os.FileMode) error {
err := os.MkdirAll(filepath.Dir(to), 0o755)
if err != nil {
return err
}
src, err := os.Open(from)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(to, os.O_TRUNC|os.O_CREATE|os.O_RDWR, perms)
if err != nil {
return err
}
defer dst.Close()
_, err = io.Copy(dst, src)
return err
}
func resolvePath(hc interp.HandlerContext, path string) string {
if !filepath.IsAbs(path) {
return filepath.Join(hc.Dir, path)
}
return path
}

View File

@ -0,0 +1,155 @@
[[translation]]
id = 1228660974
value = 'Pulling repository'
[[translation]]
id = 2779805870
value = 'Repository up to date'
[[translation]]
id = 1433222829
value = 'Would you like to view the build script for'
[[translation]]
id = 2470847050
value = 'Failed to prompt user to view build script'
[[translation]]
id = 855659503
value = 'Would you still like to continue?'
[[translation]]
id = 1997041569
value = 'User chose not to continue after reading script'
[[translation]]
id = 2347700990
value = 'Building package'
[[translation]]
id = 2105058868
value = 'Downloading sources'
[[translation]]
id = 1884485082
value = 'Downloading source'
[[translation]]
id = 1519177982
value = 'Error building package'
[[translation]]
id = 2125220917
value = 'Choose which package(s) to install'
[[translation]]
id = 812531604
value = 'Error prompting for choice of package'
[[translation]]
id = 1040982801
value = 'Updating version'
[[translation]]
id = 1014897988
value = 'Remove build dependencies?'
[[translation]]
id = 2205430948
value = 'Installing build dependencies'
[[translation]]
id = 2522710805
value = 'Installing dependencies'
[[translation]]
id = 3602138206
value = 'Error installing package'
[[translation]]
id = 2235794125
value = 'Would you like to remove build dependencies?'
[[translation]]
id = 2562049386
value = "Your system's CPU architecture doesn't match this package. Do you want to build anyway?"
[[translation]]
id = 4006393493
value = 'The checksums array must be the same length as sources'
[[translation]]
id = 3759891273
value = 'The package() function is required'
[[translation]]
id = 1057080231
value = 'Executing package()'
[[translation]]
id = 2687735200
value = 'Executing prepare()'
[[translation]]
id = 535572372
value = 'Executing version()'
[[translation]]
id = 436644691
value = 'Executing build()'
[[translation]]
id = 1393316459
value = 'This package is already installed'
[[translation]]
id = 1267660189
value = 'Source can be updated, updating if required'
[[translation]]
id = 21753247
value = 'Source found in cache, linked to destination'
[[translation]]
id = 257354570
value = 'Compressing package'
[[translation]]
id = 2952487371
value = 'Building package metadata'
[[translation]]
id = 3121791194
value = 'Running LURE as root is forbidden as it may cause catastrophic damage to your system'
[[translation]]
id = 1256604213
value = 'Waiting for torrent metadata'
[[translation]]
id = 432261354
value = 'Downloading torrent file'
[[translation]]
id = 1579384326
value = 'name'
[[translation]]
id = 3206337475
value = 'version'
[[translation]]
id = 1810056261
value = 'new'
[[translation]]
id = 1602912115
value = 'source'
[[translation]]
id = 2363381545
value = 'type'
[[translation]]
id = 3419504365
value = 'downloader'

View File

@ -0,0 +1,151 @@
[[translation]]
id = 1228660974
value = 'Скачивание репозитория'
[[translation]]
id = 2779805870
value = 'Репозиторий уже обновлен'
[[translation]]
id = 1433222829
value = 'Показать скрипт для пакета'
[[translation]]
id = 2470847050
value = 'Не удалось предложить просмотреть скрипт'
[[translation]]
id = 855659503
value = 'Продолжить?'
[[translation]]
id = 1997041569
value = 'Пользователь решил не продолжать после просмотра скрипта'
[[translation]]
id = 2347700990
value = 'Сборка пакета'
[[translation]]
id = 2105058868
value = 'Скачивание файлов'
[[translation]]
id = 1884485082
value = 'Скачивание источника'
[[translation]]
id = 1519177982
value = 'Ошибка при сборке пакета'
[[translation]]
id = 2125220917
value = 'Выберите, какие пакеты установить'
[[translation]]
id = 812531604
value = 'Ошибка при запросе выбора пакета'
[[translation]]
id = 1040982801
value = 'Обновление версии'
[[translation]]
id = 2235794125
value = 'Удалить зависимости сборки?'
[[translation]]
id = 2205430948
value = 'Установка зависимостей сборки'
[[translation]]
id = 2522710805
value = 'Установка зависимостей'
[[translation]]
id = 3602138206
value = 'Ошибка при установке пакета'
[[translation]]
id = 1057080231
value = 'Вызов функции package()'
[[translation]]
id = 2687735200
value = 'Вызов функции prepare()'
[[translation]]
id = 535572372
value = 'Вызов функции version()'
[[translation]]
id = 436644691
value = 'Вызов функции build()'
[[translation]]
id = 2562049386
value = "Архитектура процессора вашей системы не соответствует этому пакету. Продолжать несмотря на это?"
[[translation]]
id = 3759891273
value = 'Функция package() необходима'
[[translation]]
id = 4006393493
value = 'Массив checksums должен быть той же длины, что и sources'
[[translation]]
id = 1393316459
value = 'Этот пакет уже установлен'
[[translation]]
id = 1267660189
value = 'Источник может быть обновлен, если требуется, обновляем'
[[translation]]
id = 21753247
value = 'Источник найден в кэше'
[[translation]]
id = 257354570
value = 'Сжатие пакета'
[[translation]]
id = 2952487371
value = 'Создание метаданных пакета'
[[translation]]
id = 3121791194
value = 'Запуск LURE от имени root запрещен, так как это может привести к катастрофическому повреждению вашей системы'
[[translation]]
id = 1256604213
value = 'Ожидание метаданных торрента'
[[translation]]
id = 432261354
value = 'Скачивание торрент-файла'
[[translation]]
id = 1579384326
value = 'название'
[[translation]]
id = 3206337475
value = 'версия'
[[translation]]
id = 1810056261
value = 'новая'
[[translation]]
id = 1602912115
value = 'источник'
[[translation]]
id = 2363381545
value = 'вид'
[[translation]]
id = 3419504365
value = 'протокол-скачивание'

View File

@ -0,0 +1,56 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package translations
import (
"context"
"embed"
"sync"
"go.elara.ws/logger"
"lure.sh/lure/pkg/loggerctx"
"go.elara.ws/translate"
"golang.org/x/text/language"
)
//go:embed files
var translationFS embed.FS
var (
mu sync.Mutex
translator *translate.Translator
)
func Translator(ctx context.Context) *translate.Translator {
mu.Lock()
defer mu.Unlock()
log := loggerctx.From(ctx)
if translator == nil {
t, err := translate.NewFromFS(translationFS)
if err != nil {
log.Fatal("Error creating new translator").Err(err).Send()
}
translator = &t
}
return translator
}
func NewLogger(ctx context.Context, l logger.Logger, lang language.Tag) *translate.TranslatedLogger {
return translate.NewLogger(l, *Translator(ctx), lang)
}

70
internal/types/build.go Normal file
View File

@ -0,0 +1,70 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package types
import "lure.sh/lure/pkg/manager"
type BuildOpts struct {
Script string
Manager manager.Manager
Clean bool
Interactive bool
}
// BuildVars represents the script variables required
// to build a package
type BuildVars struct {
Name string `sh:"name,required"`
Version string `sh:"version,required"`
Release int `sh:"release,required"`
Epoch uint `sh:"epoch"`
Description string `sh:"desc"`
Homepage string `sh:"homepage"`
Maintainer string `sh:"maintainer"`
Architectures []string `sh:"architectures"`
Licenses []string `sh:"license"`
Provides []string `sh:"provides"`
Conflicts []string `sh:"conflicts"`
Depends []string `sh:"deps"`
BuildDepends []string `sh:"build_deps"`
OptDepends []string `sh:"opt_deps"`
Replaces []string `sh:"replaces"`
Sources []string `sh:"sources"`
Checksums []string `sh:"checksums"`
Backup []string `sh:"backup"`
Scripts Scripts `sh:"scripts"`
}
type Scripts struct {
PreInstall string `sh:"preinstall"`
PostInstall string `sh:"postinstall"`
PreRemove string `sh:"preremove"`
PostRemove string `sh:"postremove"`
PreUpgrade string `sh:"preupgrade"`
PostUpgrade string `sh:"postupgrade"`
PreTrans string `sh:"pretrans"`
PostTrans string `sh:"posttrans"`
}
type Directories struct {
BaseDir string
SrcDir string
PkgDir string
ScriptDir string
}

38
internal/types/config.go Normal file
View File

@ -0,0 +1,38 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package types
// Config represents the LURE configuration file
type Config struct {
RootCmd string `toml:"rootCmd"`
PagerStyle string `toml:"pagerStyle"`
IgnorePkgUpdates []string `toml:"ignorePkgUpdates"`
Repos []Repo `toml:"repo"`
Unsafe Unsafe `toml:"unsafe"`
}
// Repo represents a LURE repo within a configuration file
type Repo struct {
Name string `toml:"name"`
URL string `toml:"url"`
}
type Unsafe struct {
AllowRunAsRoot bool `toml:"allowRunAsRoot"`
}

26
internal/types/repo.go Normal file
View File

@ -0,0 +1,26 @@
/*
* LURE - Linux User REpository
* Copyright (C) 2023 Elara Musayelyan
*
* 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 <http://www.gnu.org/licenses/>.
*/
package types
// RepoConfig represents a LURE repo's lure-repo.toml file.
type RepoConfig struct {
Repo struct {
MinVersion string `toml:"minVersion"`
}
}