diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index a24883a5..73badcbd 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -12,8 +12,8 @@ jobs: cache: false - uses: actions/checkout@v4 - name: golangci-lint - # using golangci/golangci-lint-action@v6.5.2 below - uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 + # the action below uses golangci/golangci-lint-action@v7.0.0 + uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd with: # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version version: latest diff --git a/.golangci.yml b/.golangci.yml index 4e36cf16..624f191b 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,755 +1,135 @@ -# This file contains all available configuration options -# with their default values. - -# options for analysis running +version: "2" run: go: "1.24" - # default concurrency is a available CPU number - #concurrency: 4 - - # timeout for analysis, e.g. 30s, 5m, default is 1m - timeout: 3m - - # exit code when at least one issue was found, default is 1 issues-exit-code: 1 - - # include test files or not, default is true tests: true - - # list of build tags, all linters use it. Default is empty list. - #build-tags: - # - mytag - - # which files to skip: they will be analyzed, but issues from them - # won't be reported. Default value is empty list, but there is - # no need to include all autogenerated files, we confidently recognize - # autogenerated files. If it's not please let us know. - # "/" will be replaced by current OS file path separator to properly work - # on Windows. -# skip-files: -# - ".*\\.my\\.go$" -# - lib/bad.go - - # by default isn't set. If set we pass it to "go list -mod={option}". From "go help modules": - # If invoked with -mod=readonly, the go command is disallowed from the implicit - # automatic updating of go.mod described above. Instead, it fails when any changes - # to go.mod are needed. This setting is most useful to check that go.mod does - # not need updates, such as in a continuous integration and testing system. - # If invoked with -mod=vendor, the go command assumes that the vendor - # directory holds the correct copies of dependencies and ignores - # the dependency descriptions in go.mod. -# modules-download-mode: readonly|vendor|mod - - # Allow multiple parallel golangci-lint instances running. - # If false (default) - golangci-lint acquires file lock on start. allow-parallel-runners: false - -# output configuration options output: - # colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions - # default is "colored-line-number" formats: - - format: colored-line-number + text: path: stdout - - # print lines of code with issue, default is true - print-issued-lines: true - - # print linter name in the end of issue text, default is true - print-linter-name: true - - # add a prefix to the output file references; default is no prefix + print-linter-name: true + print-issued-lines: true path-prefix: "" - - # sorts results by: filepath, line and column - sort-results: true - -linters-settings: -# depguard: -# list-type: blacklist -# include-go-root: false -# packages: -# - github.com/sirupsen/logrus -# packages-with-error-message: -# # specify an error message to output when a blacklisted package is used -# - github.com/sirupsen/logrus: "logging is allowed only by logutils.Log" -# -# dupl: -# # tokens count to trigger issue, 150 by default -# threshold: 100 - - errcheck: - # report about not checking of errors in type assertions: `a := b.(MyStruct)`; - # default is false: such cases aren't reported by default. - check-type-assertions: false - - # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; - # default is false: such cases aren't reported by default. - check-blank: false - - # [deprecated] comma-separated list of pairs of the form pkg:regex - # the regex is used to ignore names within pkg. (default "fmt:.*"). - # see https://github.com/kisielk/errcheck#the-deprecated-method for details -# ignore: fmt:.*,io/ioutil:^Read.* - - # path to a file containing a list of functions to exclude from checking - # see https://github.com/kisielk/errcheck#excluding-functions for details -# exclude: /path/to/file.txt - -# errorlint: -# # Check whether fmt.Errorf uses the %w verb for formatting errors. See the readme for caveats -# errorf: true -# # Check for plain type assertions and type switches -# asserts: true -# # Check for plain error comparisons -# comparison: true -# -# exhaustive: -# # check switch statements in generated files also -# check-generated: false -# # indicates that switch statements are to be considered exhaustive if a -# # 'default' case is present, even if all enum members aren't listed in the -# # switch -# default-signifies-exhaustive: false -# -# exhaustivestruct: -# # Struct Patterns is list of expressions to match struct packages and names -# # The struct packages have the form example.com/package.ExampleStruct -# # The matching patterns can use matching syntax from https://pkg.go.dev/path#Match -# # If this list is empty, all structs are tested. -# struct-patterns: -# - '*.Test' -# - 'example.com/package.ExampleStruct' -# -# forbidigo: -# # Forbid the following identifiers (identifiers are written using regexp): -# forbid: -# - ^print.*$ -# - 'fmt\.Print.*' -# # Exclude godoc examples from forbidigo checks. Default is true. -# exclude_godoc_examples: false -# -# funlen: -# lines: 100 -# statements: 50 - - gci: - # Section configuration to compare against. - # Section names are case-insensitive and may contain parameters in (). - # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`, - # If `custom-order` is `true`, it follows the order of `sections` option. - # Default: ["standard", "default"] - sections: - - standard # Standard section: captures all standard packages. - - default # Default section: contains all imports that could not be matched to another section type. - - prefix(github.com/c2fo/) # Custom section: groups all imports with the specified Prefix. -# - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. -# - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. -# - alias # Alias section: contains all alias imports. This section is not present unless explicitly enabled. -# - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled. - - # Skip generated files. - # Default: true - skip-generated: true - - # Enable custom order of sections. - # If `true`, make the section order the same as the order of `sections`. - # Default: false - custom-order: false - -# gocognit: -# # minimal code complexity to report, 30 by default (but we recommend 10-20) -# min-complexity: 10 -# -# nestif: -# # minimal complexity of if statements to report, 5 by default -# min-complexity: 4 -# -# goconst: -# # minimal length of string constant, 3 by default -# min-len: 3 -# # minimal occurrences count to trigger, 3 by default -# min-occurrences: 2 - - gocritic: - # Which checks should be enabled; can't be combined with 'disabled-checks'; - # See https://go-critic.github.io/overview#checks-overview - # To check which checks are enabled run `GL_DEBUG=gocritic golangci-lint run` - # By default list of stable checks is used. -# enabled-checks: -# - rangeValCopy - - # Which checks should be disabled; can't be combined with 'enabled-checks'; default is empty - disabled-checks: - - dupImport # https://github.com/go-critic/go-critic/issues/845 - - ifElseChain - - octalLiteral - - whyNoLint - - wrapperFunc - - hugeParam - # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint run` to see all tags and checks. - # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". - enabled-tags: - - diagnostic - - experimental - - opinionated - - performance - - style - # Settings passed to gocritic. - # The settings key is the name of a supported gocritic checker. - # The list of supported checkers can be find in https://go-critic.github.io/overview. - settings: - captLocal: # must be valid enabled check name - # whether to restrict checker to params only (default true) - paramsOnly: true - elseif: - # whether to skip balanced if-else pairs (default true) - skipBalanced: true -# hugeParam: - # size in bytes that makes the warning trigger (default 80) -# sizeThreshold: 80 - nestingReduce: - # min number of statements inside a branch to trigger a warning (default 5) - bodyWidth: 5 - rangeExprCopy: - # size in bytes that makes the warning trigger (default 512) - sizeThreshold: 512 - # whether to check test functions (default true) - skipTestFuncs: true - rangeValCopy: - # size in bytes that makes the warning trigger (default 128) - sizeThreshold: 128 - # whether to check test functions (default true) - skipTestFuncs: true - truncateCmp: - # whether to skip int/uint/uintptr types (default true) - skipArchDependent: true - underef: - # whether to skip (*x).method() calls where x is a pointer receiver (default true) - skipRecvDeref: true - unnamedResult: - # whether to check exported functions - checkExported: true - - gocyclo: - # minimal code complexity to report, 30 by default (but we recommend 10-20) - min-complexity: 15 - -# godot: -# # comments to be checked: `declarations`, `toplevel`, or `all` -# scope: declarations -# # list of regexps for excluding particular comment lines from check -# exclude: -# # example: exclude comments which contain numbers -# # - '[0-9]+' -# # check that each sentence starts with a capital letter -# capital: false -# -# godox: -# # report any comments starting with keywords, this is useful for TODO or FIXME comments that -# # might be left in the code accidentally and should be resolved before merging -# keywords: # default keywords are TODO, BUG, and FIXME, these can be overwritten by this setting -# - NOTE -# - OPTIMIZE # marks code that should be optimized before merging -# - HACK # marks hack-arounds that should be removed before merging - - gofmt: - # simplify code: gofmt with `-s` option, true by default - simplify: true - -# gofumpt: -# # Select the Go version to target. The default is `1.15`. -# lang-version: "1.15" -# -# # Choose whether or not to use the extra rules that are disabled -# # by default -# extra-rules: false -# -# goheader: -# values: -# const: -# # define here const type values in format k:v, for example: -# # COMPANY: MY COMPANY -# regexp: -# # define here regexp type values, for example -# # AUTHOR: .*@mycompany\.com -# template: # |- -# # put here copyright header template for source code files, for example: -# # Note: {{ YEAR }} is a builtin value that returns the year relative to the current machine time. -# # -# # {{ AUTHOR }} {{ COMPANY }} {{ YEAR }} -# # SPDX-License-Identifier: Apache-2.0 -# -# # Licensed under the Apache License, Version 2.0 (the "License"); -# # you may not use this file except in compliance with the License. -# # You may obtain a copy of the License at: -# -# # http://www.apache.org/licenses/LICENSE-2.0 -# -# # Unless required by applicable law or agreed to in writing, software -# # distributed under the License is distributed on an "AS IS" BASIS, -# # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# # See the License for the specific language governing permissions and -# # limitations under the License. -# template-path: -# # also as alternative of directive 'template' you may put the path to file with the template source - - goimports: - # put imports beginning with prefix after 3rd-party packages; - # it's a comma-separated list of prefixes - local-prefixes: github.com/c2fo/ - -# golint: -# # minimal confidence for issues, default is 0.8 -# min-confidence: 0.8 - -# gomnd: -# settings: -# mnd: -# # the list of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. -# #checks: argument,case,condition,operation,return,assign -# checks: argument,case,condition,return -# # ignored-numbers: 1000 -# # ignored-files: magic_.*.go -# # ignored-functions: math.* -# -# gomoddirectives: -# # Allow local `replace` directives. Default is false. -# replace-local: false -# # List of allowed `replace` directives. Default is empty. -# replace-allow-list: -# - launchpad.net/gocheck -# # Allow to not explain why the version has been retracted in the `retract` directives. Default is false. -# retract-allow-no-explanation: false -# # Forbid the use of the `exclude` directives. Default is false. -# exclude-forbidden: false -# -# gomodguard: -# allowed: -# modules: # List of allowed modules -# # - gopkg.in/yaml.v2 -# domains: # List of allowed module domains -# # - golang.org -# blocked: -# modules: # List of blocked modules -# # - github.com/uudashr/go-module: # Blocked module -# # recommendations: # Recommended modules that should be used instead (Optional) -# # - golang.org/x/mod -# # reason: "`mod` is the official go.mod parser library." # Reason why the recommended module should be used (Optional) -# versions: # List of blocked module version constraints -# # - github.com/mitchellh/go-homedir: # Blocked module with version constraint -# # version: "< 1.1.0" # Version constraint, see https://github.com/Masterminds/semver#basic-comparisons -# # reason: "testing if blocked version constraint works." # Reason why the version constraint exists. (Optional) -# local_replace_directives: false - -# gosec: -# # To select a subset of rules to run. -# # Available rules: https://github.com/securego/gosec#available-rules -# includes: -# - G401 -# - G306 -# - G101 -# # To specify a set of rules to explicitly exclude. -# # Available rules: https://github.com/securego/gosec#available-rules -# excludes: -# - G204 -# # To specify the configuration of rules. -# # The configuration of rules is not fully documented by gosec: -# # https://github.com/securego/gosec#configuration -# # https://github.com/securego/gosec/blob/569328eade2ccbad4ce2d0f21ee158ab5356a5cf/rules/rulelist.go#L60-L102 -# config: -# G306: "0600" -# G101: -# pattern: "(?i)example" -# ignore_entropy: false -# entropy_threshold: "80.0" -# per_char_threshold: "3.0" -# truncate: "32" - gosec: - excludes: - # Flags for potentially-unsafe casting of ints, similar problem to globally-disabled G103 - - G115 - - gosimple: - # https://staticcheck.io/docs/options#checks - checks: [ "all", "-ST1003" ] - -# govet: -# # report about shadowed variables -# check-shadowing: true -# -# # settings per analyzer -# settings: -# printf: # analyzer name, run `go tool vet help` to see all analyzers -# funcs: # run `go tool vet help printf` to see available settings for `printf` analyzer -# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof -# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf -# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf -# - (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf -# -# # enable or disable analyzers by name -# # run `go tool vet help` to see all analyzers -# enable: -# - atomicalign -# enable-all: false -# disable: -# - shadow -# disable-all: false -# -# ifshort: -# # Maximum length of variable declaration measured in number of lines, after which linter won't suggest using short syntax. -# # Has higher priority than max-decl-chars. -# max-decl-lines: 1 -# # Maximum length of variable declaration measured in number of characters, after which linter won't suggest using short syntax. -# max-decl-chars: 30 -# -# importas: -# # if set to `true`, force to use alias. -# no-unaliased: true -# # List of aliases -# alias: -# # using `servingv1` alias for `knative.dev/serving/pkg/apis/serving/v1` package -# - pkg: knative.dev/serving/pkg/apis/serving/v1 -# alias: servingv1 -# # using `autoscalingv1alpha1` alias for `knative.dev/serving/pkg/apis/autoscaling/v1alpha1` package -# - pkg: knative.dev/serving/pkg/apis/autoscaling/v1alpha1 -# alias: autoscalingv1alpha1 -# # You can specify the package path by regular expression, -# # and alias by regular expression expansion syntax like below. -# # see https://github.com/julz/importas#use-regular-expression for details -# - pkg: knative.dev/serving/pkg/apis/(\w+)/(v[\w\d]+) -# alias: $1$2 - - lll: - # max line length, lines longer will be reported. Default is 120. - # '\t' is counted as 1 character by default, and can be changed with the tab-width option - line-length: 140 - # tab width in spaces. Default to 1. - tab-width: 1 - -# makezero: -# # Allow only slices initialized with a length of zero. Default is false. -# always: false\ - - misspell: - # Correct spellings using locale preferences for US or UK. - # Default is to use a neutral variety of English. - # Setting locale to US will correct the British spelling of 'colour' to 'color'. - locale: US -# ignore-words: -# - someword - -# nakedret: -# # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 -# max-func-lines: 30 -# -# prealloc: -# # XXX: we don't recommend using this linter before doing performance profiling. -# # For most programs usage of prealloc will be a premature optimization. -# -# # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. -# # True by default. -# simple: true -# range-loops: true # Report preallocation suggestions on range loops, true by default -# for-loops: false # Report preallocation suggestions on for loops, false by default -# -# promlinter: -# # Promlinter cannot infer all metrics name in static analysis. -# # Enable strict mode will also include the errors caused by failing to parse the args. -# strict: false -# # Please refer to https://github.com/yeya24/promlinter#usage for detailed usage. -# disabled-linters: -# # - "Help" -# # - "MetricUnits" -# # - "Counter" -# # - "HistogramSummaryReserved" -# # - "MetricTypeInName" -# # - "ReservedChars" -# # - "CamelCase" -# # - "lintUnitAbbreviations" -# -# predeclared: -# # comma-separated list of predeclared identifiers to not report on -# ignore: "" -# # include method names and field names (i.e., qualified names) in checks -# q: false - - nolintlint: - # Disable to ensure that all nolint directives actually have an effect. - # Default: false - allow-unused: false - # Exclude following linters from requiring an explanation. - # Default: [] - allow-no-explanation: [] - # Enable to require an explanation of nonzero length after each nolint directive. - # Default: false - require-explanation: false - # Enable to require nolint directives to mention the specific linter being suppressed. - # Default: false - require-specific: false - - revive: - # see https://github.com/mgechev/revive#available-rules for details. - ignore-generated-header: true - severity: warning -# rules: -# - name: indent-error-flow -# severity: warning -# - name: add-constant -# severity: warning -# arguments: -# - maxLitCount: "3" -# allowStrs: '""' -# allowInts: "0,1,2" -# allowFloats: "0.0,0.,1.0,1.,2.0,2." - - staticcheck: - # https://staticcheck.io/docs/options#checks - checks: [ "all", "-ST1003" ] - - stylecheck: - # https://staticcheck.io/docs/options#checks - checks: [ "all", "-ST1000", "-ST1003", "-ST1016", "-ST1020", "-ST1021", "-ST1022" ] -# # https://staticcheck.io/docs/options#dot_import_whitelist -# dot-import-whitelist: -# - fmt -# # https://staticcheck.io/docs/options#initialisms -# initialisms: [ "ACL", "API", "ASCII", "CPU", "CSS", "DNS", "EOF", "GUID", "HTML", "HTTP", "HTTPS", "ID", "IP", "JSON", "QPS", "RAM", "RPC", "SLA", "SMTP", "SQL", "SSH", "TCP", "TLS", "TTL", "UDP", "UI", "GID", "UID", "UUID", "URI", "URL", "UTF8", "VM", "XML", "XMPP", "XSRF", "XSS" ] -# # https://staticcheck.io/docs/options#http_status_code_whitelist -# http-status-code-whitelist: [ "200", "400", "404", "500" ] - -# tagliatelle: -# # check the struck tag name case -# case: -# # use the struct field name to check the name of the struct tag -# use-field-name: true -# rules: -# # any struct tag type can be used. -# # support string case: `camel`, `pascal`, `kebab`, `snake`, `goCamel`, `goPascal`, `goKebab`, `goSnake`, `upper`, `lower` -# json: camel -# yaml: camel -# xml: camel -# bson: camel -# avro: snake -# mapstructure: kebab -# -# testpackage: -# # regexp pattern to skip files -# skip-regexp: (export|internal)_test\.go -# -# thelper: -# # The following configurations enable all checks. It can be omitted because all checks are enabled by default. -# # You can enable only required checks deleting unnecessary checks. -# test: -# first: true -# name: true -# begin: true -# benchmark: -# first: true -# name: true -# begin: true -# tb: -# first: true -# name: true -# begin: true - - unparam: - # Inspect exported functions, default is false. Set to true if no external program/library imports your code. - # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: - # if it's called for subdir of a project it can't find external interfaces. All text editor integrations - # with golangci-lint call it on a directory with the changed file. - check-exported: false - -# whitespace: -# multi-if: false # Enforces newlines (or comments) after every multi-line if statement -# multi-func: false # Enforces newlines (or comments) after every multi-line function signature -# -# wrapcheck: -# # An array of strings that specify substrings of signatures to ignore. -# # If this set, it will override the default set of ignored signatures. -# # See https://github.com/tomarrell/wrapcheck#configuration for more information. -# ignoreSigs: -# - .Errorf( -# - errors.New( -# - errors.Unwrap( -# - .Wrap( -# - .Wrapf( -# - .WithMessage( -# -# wsl: -# # See https://github.com/bombsimon/wsl/blob/master/doc/configuration.md for -# # documentation of available settings. These are the defaults for -# # `golangci-lint`. -# allow-assign-and-anything: false -# allow-assign-and-call: true -# allow-cuddle-declarations: false -# allow-multiline-assign: true -# allow-separated-leading-comment: false -# allow-trailing-comment: false -# force-case-trailing-whitespace: 0 -# force-err-cuddling: false -# force-short-decl-cuddling: false -# strict-append: true -# -# # The custom section can be used to define linter plugins to be loaded at runtime. -# # See README doc for more info. -# custom: -# # Each custom linter should have a unique name. -# example: -# # The path to the plugin *.so. Can be absolute or local. Required for each custom linter -# path: /path/to/example.so -# # The description of the linter. Optional, just for documentation purposes. -# description: This is an example usage of a plugin linter. -# # Intended to point to the repo location of the linter. Optional, just for documentation purposes. -# original-url: github.com/golangci/example-linter - linters: - # please, do not use `enable-all`: it's deprecated and will be removed soon. - # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint - disable-all: true + default: none enable: - bodyclose -# - deadcode -# - depguard -# - dogsled -# - dupl ** - errcheck -# - exportloopref -# - exhaustive -# - funlen ** -# - gochecknoinits -# - goconst - gocritic - gocyclo - - gofmt - - goimports -# - golint -# - gomnd ** -# - goprintffuncname - gosec - - gosimple - govet - ineffassign - lll -# - maligned - misspell -# - nakedret -# - noctx - nolintlint -# - rowserrcheck - revive - staticcheck -# - structcheck - - stylecheck -# - typecheck - unconvert - unparam - unused -# - varcheck -# - whitespace - - # don't enable: - # - asciicheck - # - scopelint - # - gochecknoglobals ** - # - gocognit - # - godot - # - godox ** - # - goerr113 * - # - nestif - # - prealloc - # - testpackage - # - wsl - + settings: + errcheck: + check-type-assertions: false + check-blank: false + gocritic: + disabled-checks: + - dupImport + - ifElseChain + - octalLiteral + - whyNoLint + - wrapperFunc + - hugeParam + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + settings: + captLocal: + paramsOnly: true + elseif: + skipBalanced: true + nestingReduce: + bodyWidth: 5 + rangeExprCopy: + sizeThreshold: 512 + skipTestFuncs: true + rangeValCopy: + sizeThreshold: 128 + skipTestFuncs: true + truncateCmp: + skipArchDependent: true + underef: + skipRecvDeref: true + unnamedResult: + checkExported: true + gocyclo: + min-complexity: 15 + gosec: + excludes: + - G115 + lll: + line-length: 140 + tab-width: 1 + misspell: + locale: US + nolintlint: + require-explanation: false + require-specific: false + allow-unused: false + revive: + severity: warning + staticcheck: + checks: + - -ST1000 + - -ST1003 + - -ST1016 + - -ST1020 + - -ST1021 + - -ST1022 + - all + unparam: + check-exported: false + exclusions: + generated: lax + rules: + - linters: + - mnd + path: _test\.go + - linters: + - lll + source: '^//go:generate ' + paths: + - docs + - mocks issues: - # Excluding configuration per-path, per-linter, per-text and per-source - exclude-rules: - - path: _test\.go - linters: - - gomnd - # Exclude lll issues for long lines with go:generate - - linters: - - lll - source: "^//go:generate " - - # Independently from option `exclude` we use default exclude patterns, - # it can be disabled by this option. To list all - # excluded by default patterns execute `golangci-lint run --help`. - # Default value for this option is true. - exclude-use-default: false - - # The default value is false. If set to true exclude and exclude-rules - # regular expressions become case sensitive. - exclude-case-sensitive: false - - # Which dirs to exclude: issues from them won't be reported. - # Can use regexp here: `generated.*`, regexp is applied on full path, - # including the path prefix if one is set. - # Default dirs are skipped independently of this option's value (see exclude-dirs-use-default). - # "/" will be replaced by current OS file path separator to properly work on Windows. - # Default: [] - exclude-dirs: - - docs - - mocks - - # Enables exclude of directories: - # - vendor$, third_party$, testdata$, examples$, Godeps$, builtin$ - # Default: true - exclude-dirs-use-default: false - - # The list of ids of default excludes to include or disable. By default it's empty. -# include: -# - EXC0002 # disable excluding of issues about comments from golint - - # Maximum issues count per one linter. Set to 0 to disable. Default is 50. max-issues-per-linter: 50 - - # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. max-same-issues: 3 - - # Show only new issues: if there are unstaged changes or untracked files, - # only those changes are analyzed, else only changes in HEAD~ are analyzed. - # It's a super-useful option for integration of golangci-lint into existing - # large codebase. It's not practical to fix all existing issues at the moment - # of integration: much better don't allow issues in new code. - # Default is false. - new: false - - # Make issues output unique by line. - # Default: true uniq-by-line: true - - # Show only new issues created after git revision `REV` - #new-from-rev: REV - - # Show only new issues created in git patch with set file path. - #new-from-patch: path/to/patch/file - - # Fix found issues (if it's supported by the linter) - #fix: true - + new: false severity: - # Default value is empty string. - # Set the default severity for issues. If severity rules are defined and the issues - # do not match or no severity is provided to the rule this will be the default - # severity applied. Severities should match the supported severity names of the - # selected out format. - # - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity - # - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#severity - # - Github: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message - default-severity: error - - # The default value is false. - # If set to true severity-rules regular expressions become case sensitive. - case-sensitive: false - - # Default value is empty list. - # When a list of severity rules are provided, severity information will be added to lint - # issues. Severity rules have the same filtering capability as exclude rules except you - # are allowed to specify one matcher per severity rule. - # Only affects out formats that support setting severity information. -# rules: -# - linters: -# - dupl -# severity: info - + default: error +formatters: + enable: + - gofmt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/c2fo/) + custom-order: false + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com/c2fo/ + exclusions: + generated: lax + paths: + - docs + - mocks diff --git a/CHANGELOG.md b/CHANGELOG.md index 4488dff1..5ce7b996 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v7.4.0] - 2025-04-25 +### Added +- contrib/lockfile which provides a portable advisory locking mechanism for VFS files that works across different backends. +### Security +- Updated golangci-lint config (v2) and related gh action (v7). + ## [v7.3.0] - 2025-04-15 ### Added - Add more error wrapping to public functions. Fixes #176. diff --git a/README.md b/README.md index 5320b592..6004a1ab 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ abstract backend details. This allows you to: By focusing on *[FileSystem](https://pkg.go.dev/github.com/c2fo/vfs/v7#FileSystem)*, *[Location](https://pkg.go.dev/github.com/c2fo/vfs/v7#Location)*, and *[File](https://pkg.go.dev/github.com/c2fo/vfs/v7#File)* interfaces, you can build reusable flows for file operations without -needing to deeply understand each backend’s specific APIs. Users can add or swap backends as needed, providing flexibility +needing to deeply understand each backend's specific APIs. Users can add or swap backends as needed, providing flexibility for hybrid or evolving storage requirements. ## Installation @@ -82,6 +82,10 @@ This snippet shows the basic setup: an osFile is created from a URI and written * [azure backend](docs/azure.md) * [utils](docs/utils.md) +## Additional Tools + +* [lockfile](contrib/lockfile/README.md): Provides distributed locking mechanisms using vfs backends. + ## FAQ **Q**: Why am I seeing an empty file when using `io.Copy` on some backends if my source is empty? diff --git a/backend/azure/client.go b/backend/azure/client.go index ba53d91f..985a77b7 100644 --- a/backend/azure/client.go +++ b/backend/azure/client.go @@ -169,7 +169,7 @@ func (a *DefaultClient) Download(file vfs.File) (io.ReadCloser, error) { // error. func (a *DefaultClient) Copy(srcFile, tgtFile vfs.File) error { // Can't use url.PathEscape here since that will escape everything (even the directory separators) - srcURL := strings.Replace(srcFile.Path(), "%", "%25", -1) + srcURL := strings.ReplaceAll(srcFile.Path(), "%", "%25") srcURL = a.serviceURL.JoinPath(srcFile.Location().Authority().String(), srcURL).String() tgtURL := tgtFile.Location().Authority().String() @@ -216,7 +216,7 @@ func (a *DefaultClient) List(l vfs.Location) ([]string, error) { return []string{}, err } - for i := range listBlob.ListBlobsHierarchySegmentResponse.Segment.BlobItems { + for i := range listBlob.Segment.BlobItems { list = append(list, *listBlob.ListBlobsHierarchySegmentResponse.Segment.BlobItems[i].Name) } } @@ -278,7 +278,7 @@ func (a *DefaultClient) getBlobVersions(cli *container.Client, blobName string) return []*string{}, err } - for i := range listBlob.ListBlobsFlatSegmentResponse.Segment.BlobItems { + for i := range listBlob.Segment.BlobItems { versions = append(versions, listBlob.ListBlobsFlatSegmentResponse.Segment.BlobItems[i].VersionID) } } diff --git a/backend/azure/client_test.go b/backend/azure/client_test.go index 90d7de6b..b948e571 100644 --- a/backend/azure/client_test.go +++ b/backend/azure/client_test.go @@ -258,22 +258,23 @@ func TestDefaultClient_Delete(t *testing.T) { func TestDefaultClient_DeleteAllVersions(t *testing.T) { // Create a mock server mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodDelete { + switch r.Method { + case http.MethodDelete: w.WriteHeader(http.StatusAccepted) - } else if r.Method == http.MethodGet { + case http.MethodGet: w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(` - - - - 1 - - - 2 - - - `)) - } else { + + + + 1 + + + 2 + + + `)) + default: w.WriteHeader(http.StatusMethodNotAllowed) } })) diff --git a/backend/azure/options.go b/backend/azure/options.go index 65c3107f..c9b97227 100644 --- a/backend/azure/options.go +++ b/backend/azure/options.go @@ -36,7 +36,7 @@ type Options struct { // RetryFunc holds the retry function // // Deprecated: This field is deprecated and will be removed in a future release. - RetryFunc vfs.Retry + RetryFunc vfs.Retry //nolint:staticcheck // Deprecated: This field is deprecated and will be removed in a future release. // Buffer Size In Bytes Used with utils.TouchCopyBuffered FileBufferSize int diff --git a/backend/ftp/dataconn.go b/backend/ftp/dataconn.go index fdcbc44c..2995b0c7 100644 --- a/backend/ftp/dataconn.go +++ b/backend/ftp/dataconn.go @@ -194,7 +194,7 @@ func openWriteConnection(client types.Client, f *File) (types.DataConn, error) { err := client.MakeDir(f.Location().Path()) if err != nil { var e *textproto.Error - if !(errors.As(err, &e) && e.Code == _ftp.StatusFileUnavailable) { + if !errors.As(err, &e) || e.Code != _ftp.StatusFileUnavailable { // Return if the error is not because the directory already exists return nil, err } diff --git a/backend/ftp/fileSystem_test.go b/backend/ftp/fileSystem_test.go index 88c29e38..788c5e82 100644 --- a/backend/ftp/fileSystem_test.go +++ b/backend/ftp/fileSystem_test.go @@ -112,6 +112,7 @@ func (ts *fileSystemTestSuite) TestScheme() { } func (ts *fileSystemTestSuite) TestRetry() { + //nolint:staticcheck // SA1019 ts.IsType(vfs.DefaultRetryer(), ts.ftpfs.Retry(), "expected scheme found") } diff --git a/backend/ftp/options_test.go b/backend/ftp/options_test.go index 1152e18b..6d228858 100644 --- a/backend/ftp/options_test.go +++ b/backend/ftp/options_test.go @@ -116,7 +116,7 @@ func (s *optionsSuite) TestFetchPassword() { }, } - for _, test := range tests { + for _, test := range tests { //nolint:gocritic //rangeValCopy but changing breaks ide integration for table-driven tests s.Run(test.description, func() { if test.envVar != nil { err := os.Setenv(envPassword, *test.envVar) @@ -234,7 +234,7 @@ func (s *optionsSuite) TestIsDisableEPSV() { }, } - for _, test := range tests { + for _, test := range tests { //nolint:gocritic //rangeValCopy but changing breaks ide integration for table-driven tests s.Run(test.description, func() { if test.envVar != nil { err := os.Setenv(envDisableEPSV, *test.envVar) @@ -378,7 +378,7 @@ func (s *optionsSuite) TestFetchProtocol() { }, } - for _, test := range tests { + for _, test := range tests { //nolint:gocritic //rangeValCopy but changing breaks ide integration for table-driven tests s.Run(test.description, func() { s.NoError(os.Unsetenv(envProtocol)) if test.envVar != nil { diff --git a/backend/ftp/types/types.go b/backend/ftp/types/types.go index 4f93d044..2d0f0462 100644 --- a/backend/ftp/types/types.go +++ b/backend/ftp/types/types.go @@ -1,3 +1,4 @@ +// Package types provides types and interfaces for FTP operations. package types import ( diff --git a/backend/os/fileSystem_test.go b/backend/os/fileSystem_test.go index 95125cea..c9302609 100644 --- a/backend/os/fileSystem_test.go +++ b/backend/os/fileSystem_test.go @@ -19,7 +19,7 @@ type osFileSystemTest struct { func (o *osFileSystemTest) TestRetry() { fs := &FileSystem{} - o.IsType(vfs.DefaultRetryer(), fs.Retry()) + o.IsType(vfs.DefaultRetryer(), fs.Retry()) //nolint:staticcheck // deprecated } func (o *osFileSystemTest) TestName() { diff --git a/backend/sftp/fileSystem_test.go b/backend/sftp/fileSystem_test.go index 5827e6a9..71f0d9fb 100644 --- a/backend/sftp/fileSystem_test.go +++ b/backend/sftp/fileSystem_test.go @@ -106,7 +106,7 @@ func (ts *fileSystemTestSuite) TestScheme() { } func (ts *fileSystemTestSuite) TestRetry() { - ts.IsType(vfs.DefaultRetryer(), ts.sftpfs.Retry(), "expected scheme found") + ts.IsType(vfs.DefaultRetryer(), ts.sftpfs.Retry(), "expected scheme found") //nolint:staticcheck // deprecated } func (ts *fileSystemTestSuite) TestWithOptions() { diff --git a/backend/sftp/options.go b/backend/sftp/options.go index f6ffb3c2..ac025fd7 100644 --- a/backend/sftp/options.go +++ b/backend/sftp/options.go @@ -120,7 +120,7 @@ func GetClient(authority authority.Authority, opts Options) (sftpclient *_sftp.C } // Define the Client Config - config := getSShConfig(opts) + config := getSSHConfig(opts) // override with env var, if any if _, ok := os.LookupEnv("VFS_SFTP_USERNAME"); ok { config.User = os.Getenv("VFS_SFTP_USERNAME") @@ -154,8 +154,8 @@ func GetClient(authority authority.Authority, opts Options) (sftpclient *_sftp.C return sftpClient, sshConn, nil } -// getSShConfig gets ssh config from Options -func getSShConfig(opts Options) *ssh.ClientConfig { +// getSSHConfig gets ssh config from Options +func getSSHConfig(opts Options) *ssh.ClientConfig { // copy default config config := *defaultSSHConfig @@ -164,13 +164,13 @@ func getSShConfig(opts Options) *ssh.ClientConfig { config.HostKeyAlgorithms = opts.HostKeyAlgorithms } if opts.Ciphers != nil { - config.Config.Ciphers = opts.Ciphers + config.Ciphers = opts.Ciphers } if opts.KeyExchanges != nil { - config.Config.KeyExchanges = opts.KeyExchanges + config.KeyExchanges = opts.KeyExchanges } if opts.MACs != nil { - config.Config.MACs = opts.MACs + config.MACs = opts.MACs } return &config diff --git a/backend/sftp/options_test.go b/backend/sftp/options_test.go index bee9b0a1..c4ef160b 100644 --- a/backend/sftp/options_test.go +++ b/backend/sftp/options_test.go @@ -570,8 +570,8 @@ func (o *optionsSuite) TestGetSSHConfig() { HostKeyAlgorithms: defaultSSHConfig.HostKeyAlgorithms, Config: ssh.Config{ Ciphers: []string{"aes128-ctr", "aes192-ctr", "aes256-ctr"}, - MACs: defaultSSHConfig.Config.MACs, - KeyExchanges: defaultSSHConfig.Config.KeyExchanges, + MACs: defaultSSHConfig.MACs, + KeyExchanges: defaultSSHConfig.KeyExchanges, }, }, }, @@ -583,9 +583,9 @@ func (o *optionsSuite) TestGetSSHConfig() { expect: &ssh.ClientConfig{ HostKeyAlgorithms: defaultSSHConfig.HostKeyAlgorithms, Config: ssh.Config{ - Ciphers: defaultSSHConfig.Config.Ciphers, + Ciphers: defaultSSHConfig.Ciphers, MACs: []string{""}, - KeyExchanges: defaultSSHConfig.Config.KeyExchanges, + KeyExchanges: defaultSSHConfig.KeyExchanges, }, }, }, @@ -597,8 +597,8 @@ func (o *optionsSuite) TestGetSSHConfig() { expect: &ssh.ClientConfig{ HostKeyAlgorithms: defaultSSHConfig.HostKeyAlgorithms, Config: ssh.Config{ - Ciphers: defaultSSHConfig.Config.Ciphers, - MACs: defaultSSHConfig.Config.MACs, + Ciphers: defaultSSHConfig.Ciphers, + MACs: defaultSSHConfig.MACs, KeyExchanges: []string{"diffie-hellman-group-exchange-sha256", "ecdh-sha2-nistp256"}, }, }, @@ -607,7 +607,7 @@ func (o *optionsSuite) TestGetSSHConfig() { for _, tc := range tests { //nolint:gocritic // rangeValCopy o.Run(tc.name, func() { - result := getSShConfig(tc.opts) + result := getSSHConfig(tc.opts) o.Equal(tc.expect, result) }) } diff --git a/contrib/lockfile/README.md b/contrib/lockfile/README.md new file mode 100644 index 00000000..4c6c069f --- /dev/null +++ b/contrib/lockfile/README.md @@ -0,0 +1,255 @@ +# VFS Lockfile Utility + +A portable advisory locking mechanism for VFS files that works across different backends (local filesystem, S3, GCS, SFTP, etc.). + +## Overview + +This package provides a simple yet powerful way to coordinate access to files across multiple processes or machines. It implements advisory locking using companion `.lock` files, which is a common pattern in distributed systems where mandatory locking is unavailable or unreliable. + +### Key Features + +- **Portable**: Works with any VFS backend that supports basic file operations +- **Advisory Locking**: Uses sidecar `.lock` files with atomic creation +- **Metadata Support**: Includes timestamp, PID, hostname, and optional TTL +- **Stale Lock Detection**: Automatic detection and handling of stale locks +- **TTL Support**: Optional time-to-live for locks +- **Owner Identification**: Optional owner ID for tracking lock ownership +- **Convenience Wrapper**: Simple `WithLock` function for scoped locking + +## Installation + +```bash +go get github.com/c2fo/vfs/contrib/lockfile +``` + +## Usage + +### Basic Usage + +```go +import ( + "github.com/c2fo/vfs/v7/vfssimple" + "github.com/c2fo/vfs/v7/contrib/lockfile" +) + +// Create a file using vfssimple +f, err := vfssimple.NewFile("mem:///inbox/data.csv") +if err != nil { + log.Fatal(err) +} + +// Create a lock for the file +lock, err := lockfile.NewLock(f) +if err != nil { + log.Fatal(err) +} + +// Try to acquire the lock +if err := lock.Acquire(); err != nil { + if errors.Is(err, lockfile.ErrLockAlreadyHeld) { + log.Println("File is already being processed") + return + } + log.Fatal(err) +} +defer lock.Release() // Always release the lock when done + +// Safely process the file +``` + +### Using the WithLock Convenience Wrapper + +```go +// Basic usage +err := lockfile.WithLock(f, func(f vfs.File) error { + // Safely process the file within this scope + // The lock is automatically released when the function returns + return nil +}) +if err != nil { + if errors.Is(err, lockfile.ErrLockAlreadyHeld) { + log.Println("File is already being processed") + return + } + log.Fatal(err) +} + +// With TTL and owner ID +err := lockfile.WithLock(f, func(f vfs.File) error { + // Process file with 5-minute timeout and owner identification + return nil +}, lockfile.WithTTL(5*time.Minute), lockfile.WithOwnerID("ingestion-service-1")) + +// With stale lock handler +err := lockfile.WithLock(f, func(f vfs.File) error { + // Process file with stale lock handling + return nil +}, lockfile.WithTTL(5*time.Minute), lockfile.OnStale(func(meta lockfile.Metadata) error { + log.Printf("Found stale lock from %s (PID: %d)", meta.Hostname, meta.PID) + return nil // Allow stealing the lock +})) +``` + +### Advanced WithLock Usage + +```go +// Using WithLock with TTL and error handling +err := lockfile.WithLock(f, func(f vfs.File) error { + // Read the file contents + data, err := io.ReadAll(f) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + // Process the data + processed, err := processData(data) + if err != nil { + return fmt.Errorf("failed to process data: %w", err) + } + // Create archive vfs.File + archiveFile, err := f.Location().NewFile("archive/" + f.Name()) + if err != nil { + return fmt.Errorf("failed to create archive file: %w", err) + } + + // Move original file to archive + if err := f.MoveToFile(archiveFile); err != nil { + return fmt.Errorf("failed to move file to archive: %w", err) + } + + + return nil +}) + +// Handle different types of errors +switch { +case errors.Is(err, lockfile.ErrLockAlreadyHeld): + log.Println("File is locked by another process") + // Implement retry logic or queue the operation +case err != nil: + log.Printf("Failed to process file: %v", err) + // Handle other errors +default: + log.Println("File processed successfully") +} +``` + +### Using TTLs + +```go +// Create a lock with a 5-minute TTL +lock, err := lockfile.NewLock(f, lockfile.WithTTL(5 * time.Minute)) +``` + +### Handling Stale Locks + +```go +// Create a lock with stale handler +lock, err := lockfile.NewLock(f, + lockfile.WithTTL(5 * time.Minute), + lockfile.OnStale(func(meta lockfile.Metadata) error { + log.Printf("Found stale lock from %s (PID: %d)", meta.Hostname, meta.PID) + return nil // Allow stealing the lock + }), +) +``` + +### Using Owner IDs + +```go +// Create a lock with owner identification +lock, err := lockfile.NewLock(f, lockfile.WithOwnerID("ingestion-service-1")) +``` + +### Checking Lock Status + +```go +// Get lock metadata +meta, err := lock.Metadata() +if err == nil { + log.Printf("Lock held by %s (PID: %d) since %v", + meta.Hostname, meta.PID, meta.CreatedAt) +} + +// Check lock age +age, err := lock.Age() +if err == nil { + log.Printf("Lock age: %v", age) +} +``` + +## Best Practices + +1. **Always Use TTLs**: Set appropriate TTLs for long-running operations +2. **Implement Cleanup**: Use `OnStale` handlers to clean up stale locks +3. **Use defer**: Always use `defer lock.Release()` to ensure locks are released +4. **Document Strategy**: Document your locking strategy and ensure all services follow it +5. **Error Handling**: Implement proper error handling and retry logic for transient failures +6. **Consider WithLock**: Use the `WithLock` wrapper for simpler, scoped locking + +## Implementation Details + +The lockfile utility works by: + +1. Creating a companion `.lock` file next to the target file +2. Writing metadata (timestamp, PID, hostname, TTL) to the lock file +3. Using atomic file operations to ensure consistency +4. Checking for stale locks based on TTL +5. Allowing lock stealing when appropriate + +## Backend-Specific Considerations + +### S3 and Eventual Consistency + +When using S3, be aware of its eventual consistency model: + +1. **Read-After-Write Consistency**: + - New objects (PUTs) are immediately available + - Updates to existing objects (PUTs) may take time to propagate + - Deletes may take time to propagate + +2. **Implications for Locking**: + - A successful lock acquisition may not be immediately visible to all readers + - Multiple processes might temporarily see different lock states + - Deleted locks might still appear to exist for some time + +3. **Mitigation Strategies**: + ```go + // Use longer TTLs to account for propagation delays + lock, err := lockfile.NewLock(f, lockfile.WithTTL(10 * time.Minute)) + + // Implement retry logic with exponential backoff + var err error + for i := 0; i < 3; i++ { + if err = lock.Acquire(); err == nil { + break + } + if errors.Is(err, lockfile.ErrLockAlreadyHeld) { + // Wait longer between retries to allow for consistency + time.Sleep(time.Second * time.Duration(math.Pow(2, float64(i)))) + continue + } + break + } + ``` + +4. **Best Practices for S3**: + - Use longer TTLs than you would with local filesystems + - Implement retry logic with exponential backoff + - Consider using S3's strong consistency features where available + - Monitor for lock contention and adjust TTLs accordingly + +## Limitations + +- This is an **advisory lock** - it only works if all processes respect the lock +- Network partitions or process crashes may leave stale locks +- Not suitable for high-contention scenarios (consider Redis or other distributed locks) +- S3's eventual consistency may affect lock visibility and reliability + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/contrib/lockfile/lockfile.go b/contrib/lockfile/lockfile.go new file mode 100644 index 00000000..735935f7 --- /dev/null +++ b/contrib/lockfile/lockfile.go @@ -0,0 +1,265 @@ +/* +Package lockfile provides an advisory locking mechanism for vfs.File objects +using companion `.lock` files. + +⚠️ Advisory Locking Note: + +This package implements **advisory locks**, which means: +- Locks are **not enforced by the OS, filesystem, or remote backend** +- All cooperating processes must **explicitly check for and honor** the lock +- It is still possible for non-cooperative processes to ignore the lock and access the file + +Lock files are created using atomic file creation (write to .tmp -> MoveTo ) +and include metadata such as timestamp, PID, hostname, and optional TTL. + +This approach is portable and backend-agnostic, and it works on local filesystems, +cloud object storage (e.g., S3/GCS), SFTP, and any vfs backend that supports +basic file creation and deletion. + +This is similar in spirit to common conventions used by package managers, editors, +and distributed systems where mandatory locking is unavailable or unreliable. +*/ +package lockfile + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "time" + + "github.com/c2fo/vfs/v7" +) + +var ( + ErrLockAlreadyHeld = errors.New("lockfile: lock already held") + ErrLockNotHeld = errors.New("lockfile: no lock held") +) + +// Metadata contains information about the current lock holder. +type Metadata struct { + CreatedAt time.Time `json:"created_at"` + TTL time.Duration `json:"ttl"` + Hostname string `json:"hostname"` + PID int `json:"pid"` + OwnerID string `json:"owner_id,omitempty"` +} + +// StaleHandler is a function that is called when a lock is stale. +// It is called with the lock metadata and should return an error if the lock should not be acquired. +// If the error is nil, the lock will be acquired. +type StaleHandler func(meta Metadata) error + +// Lock provides a portable advisory locking mechanism for files. +// It uses a separate lock file with metadata to implement the lock. +// The lock is advisory, meaning it only works if all processes using the file +// respect the lock. +type Lock struct { + // lockedFile is the file being protected by the lock + lockedFile vfs.File + // lockFile is the file containing the lock metadata + lockFile vfs.File + // metadata contains information about the current lock holder + metadata Metadata + // ttl is the time-to-live for the lock. If 0, the lock never expires. + ttl time.Duration + // ownerID is an optional identifier for the lock owner + ownerID string + // onStale is called when attempting to acquire a stale lock + onStale StaleHandler + // metadataRead caches the last read metadata + metadataRead *Metadata +} + +// Option is a function that configures a Lock. +type Option func(*Lock) + +// WithTTL sets the time-to-live for the lock. +// If the lock is not acquired within this time, it will be considered stale. +// If the TTL is 0, the lock never expires. +func WithTTL(ttl time.Duration) Option { + return func(l *Lock) { + l.ttl = ttl + } +} + +// WithOwnerID sets the owner ID for the lock. +// This is an optional identifier for the lock owner. +func WithOwnerID(owner string) Option { + return func(l *Lock) { + l.ownerID = owner + } +} + +// OnStale sets the function to call when the lock is stale. +// The function is called with the lock metadata and should return an error if the lock should not be acquired. +// If the error is nil, the lock will be acquired. +func OnStale(hook StaleHandler) Option { + return func(l *Lock) { + l.onStale = hook + } +} + +// NewLock creates a new Lock instance for the given file. +// The lock file will be created at the same location as the target file +// with a ".lock" extension. +func NewLock(f vfs.File, opts ...Option) (*Lock, error) { + lockFile, err := f.Location().NewFile(f.Name() + ".lock") + if err != nil { + return nil, fmt.Errorf("lockfile: error creating lock file: %w", err) + } + + lock := &Lock{ + lockedFile: f, + lockFile: lockFile, + ttl: 0, + } + + for _, opt := range opts { + opt(lock) + } + + return lock, nil +} + +// Acquire attempts to acquire the lock. +// If the lock is already held and not stale, returns ErrLockAlreadyHeld. +// If the lock is stale, it will be acquired after calling the OnStale handler if set. +// The operation is atomic using a temporary file to ensure consistency. +func (l *Lock) Acquire() error { + exists, err := l.lockFile.Exists() + if err != nil { + return fmt.Errorf("lockfile: error checking lock existence: %w", err) + } + if exists { + stale, meta, err := l.IsStale() + if err != nil { + return fmt.Errorf("lockfile: error checking staleness: %w", err) + } + if !stale { + return ErrLockAlreadyHeld + } + if l.onStale != nil { + if err := l.onStale(*meta); err != nil { + return fmt.Errorf("lockfile: stale handler blocked lock steal: %w", err) + } + } + if err := l.lockFile.Delete(); err != nil { + return fmt.Errorf("lockfile: error deleting stale lock: %w", err) + } + } + + hostname, err := os.Hostname() + if err != nil { + hostname = "unknown" + } + + l.metadata = Metadata{ + CreatedAt: time.Now().UTC(), + TTL: l.ttl, + PID: os.Getpid(), + Hostname: hostname, + OwnerID: l.ownerID, + } + + data, err := json.MarshalIndent(l.metadata, "", " ") + if err != nil { + return fmt.Errorf("lockfile: error marshaling metadata: %w", err) + } + + // Use a temporary file for atomic write + tmp, err := l.lockFile.Location().NewFile(l.lockFile.Name() + ".tmp") + if err != nil { + return fmt.Errorf("lockfile: error creating temp file: %w", err) + } + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("lockfile: error writing lock data: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("lockfile: error closing temp file: %w", err) + } + + return tmp.MoveToFile(l.lockFile) +} + +// Release releases the lock if it is currently held. +// If the lock file does not exist, it is treated as a successful release. +func (l *Lock) Release() error { + if l.lockFile == nil { + return nil + } + exists, err := l.lockFile.Exists() + if err != nil { + return fmt.Errorf("lockfile: error checking lock existence: %w", err) + } + if !exists { + return nil + } + return l.lockFile.Delete() +} + +// Age returns the duration since the lock was created. +// Returns an error if the lock metadata cannot be read. +func (l *Lock) Age() (time.Duration, error) { + meta, err := l.Metadata() + if err != nil { + return 0, fmt.Errorf("lockfile: error getting lock age: %w", err) + } + return time.Since(meta.CreatedAt), nil +} + +// Metadata returns the current lock metadata. +// The result is cached for subsequent calls. +func (l *Lock) Metadata() (*Metadata, error) { + if l.metadataRead != nil { + return l.metadataRead, nil + } + + defer func() { _ = l.lockFile.Close() }() + + var meta Metadata + if err := json.NewDecoder(l.lockFile).Decode(&meta); err != nil { + return nil, fmt.Errorf("lockfile: error decoding metadata: %w", err) + } + l.metadataRead = &meta + return &meta, nil +} + +// IsStale checks if the current lock is stale based on its TTL. +// Returns true if the lock is stale, along with the lock metadata. +// A lock with zero TTL is never considered stale. +func (l *Lock) IsStale() (bool, *Metadata, error) { + meta, err := l.Metadata() + if err != nil { + return false, nil, fmt.Errorf("lockfile: error checking lock staleness: %w", err) + } + // If TTL is zero, the lock never expires + if meta.TTL == 0 { + return false, meta, nil + } + expiry := meta.CreatedAt.Add(meta.TTL) + return time.Now().After(expiry), meta, nil +} + +// LockFile returns the underlying lock file. +// This is exposed for testing purposes only. +func (l *Lock) LockFile() vfs.File { + return l.lockFile +} + +// WithLock provides a convenient way to acquire a lock, execute a function, and release the lock. +// The lock is automatically released when the function returns, even if it panics. +// This is useful for scoped locking where you want to ensure the lock is always released. +func WithLock(f vfs.File, fn func(vfs.File) error, opts ...Option) error { + lock, err := NewLock(f, opts...) + if err != nil { + return err + } + if err := lock.Acquire(); err != nil { + return err + } + defer func() { _ = lock.Release() }() + return fn(f) +} diff --git a/contrib/lockfile/lockfile_test.go b/contrib/lockfile/lockfile_test.go new file mode 100644 index 00000000..8b319c0f --- /dev/null +++ b/contrib/lockfile/lockfile_test.go @@ -0,0 +1,307 @@ +package lockfile_test + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/c2fo/vfs/v7" + "github.com/c2fo/vfs/v7/backend/mem" + "github.com/c2fo/vfs/v7/contrib/lockfile" +) + +func TestNewLock(t *testing.T) { + tests := []struct { + name string + filePath string + opts []lockfile.Option + expectedError error + }{ + { + name: "valid file path", + filePath: "/test.txt", + opts: nil, + expectedError: nil, + }, + { + name: "with TTL", + filePath: "/test.txt", + opts: []lockfile.Option{lockfile.WithTTL(5 * time.Second)}, + expectedError: nil, + }, + { + name: "with owner ID", + filePath: "/test.txt", + opts: []lockfile.Option{lockfile.WithOwnerID("test-owner")}, + expectedError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", tt.filePath) + require.NoError(t, err) + + lock, err := lockfile.NewLock(f, tt.opts...) + if tt.expectedError != nil { + require.Error(t, err) + require.ErrorIs(t, err, tt.expectedError) + return + } + require.NoError(t, err) + require.NotNil(t, lock) + }) + } +} + +func TestAcquireAndRelease(t *testing.T) { + tests := []struct { + name string + ttl time.Duration + shouldAcquire bool + shouldRelease bool + }{ + { + name: "acquire and release with no TTL", + ttl: 0, + shouldAcquire: true, + shouldRelease: true, + }, + { + name: "acquire and release with TTL", + ttl: 5 * time.Second, + shouldAcquire: true, + shouldRelease: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + require.NoError(t, err) + + lock, err := lockfile.NewLock(f, lockfile.WithTTL(tt.ttl)) + require.NoError(t, err) + + if tt.shouldAcquire { + err = lock.Acquire() + require.NoError(t, err) + + // Verify lock file exists + exists, err := lock.LockFile().Exists() + require.NoError(t, err) + require.True(t, exists) + } + + if tt.shouldRelease { + err = lock.Release() + require.NoError(t, err) + + // Verify lock file is gone + exists, err := lock.LockFile().Exists() + require.NoError(t, err) + require.False(t, exists) + } + }) + } +} + +func TestConcurrentLocks(t *testing.T) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + require.NoError(t, err) + + // First lock + lock1, err := lockfile.NewLock(f) + require.NoError(t, err) + err = lock1.Acquire() + require.NoError(t, err) + + // Second lock attempt should fail + lock2, err := lockfile.NewLock(f) + require.NoError(t, err) + err = lock2.Acquire() + require.ErrorIs(t, err, lockfile.ErrLockAlreadyHeld) + + // Release first lock + err = lock1.Release() + require.NoError(t, err) + + // Second lock should now succeed + err = lock2.Acquire() + require.NoError(t, err) + err = lock2.Release() + require.NoError(t, err) +} + +func TestMetadata(t *testing.T) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + require.NoError(t, err) + + lock, err := lockfile.NewLock(f, lockfile.WithTTL(5*time.Second), lockfile.WithOwnerID("test-owner")) + require.NoError(t, err) + + err = lock.Acquire() + require.NoError(t, err) + + meta, err := lock.Metadata() + require.NoError(t, err) + require.NotNil(t, meta) + require.Equal(t, "test-owner", meta.OwnerID) + require.Equal(t, 5*time.Second, meta.TTL) + require.NotEmpty(t, meta.Hostname) + require.NotZero(t, meta.PID) + require.WithinDuration(t, time.Now(), meta.CreatedAt, time.Second) + + err = lock.Release() + require.NoError(t, err) +} + +func TestAge(t *testing.T) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + require.NoError(t, err) + + lock, err := lockfile.NewLock(f) + require.NoError(t, err) + + err = lock.Acquire() + require.NoError(t, err) + + age, err := lock.Age() + require.NoError(t, err) + require.True(t, age >= 0) + require.True(t, age < time.Second) // Should be very recent + + err = lock.Release() + require.NoError(t, err) +} + +func TestStaleLock(t *testing.T) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + require.NoError(t, err) + + // Create a lock with a very short TTL + lock1, err := lockfile.NewLock(f, lockfile.WithTTL(100*time.Millisecond)) + require.NoError(t, err) + + err = lock1.Acquire() + require.NoError(t, err) + + // Wait for lock to become stale + time.Sleep(200 * time.Millisecond) + + // Create second lock with stale handler + staleHandlerCalled := false + lock2, err := lockfile.NewLock(f, + lockfile.WithTTL(5*time.Second), + lockfile.OnStale(func(meta lockfile.Metadata) error { + staleHandlerCalled = true + return nil + }), + ) + require.NoError(t, err) + + // Should be able to acquire the stale lock + err = lock2.Acquire() + require.NoError(t, err) + require.True(t, staleHandlerCalled) + + err = lock2.Release() + require.NoError(t, err) +} + +func TestErrorCases(t *testing.T) { + tests := []struct { + name string + setup func() (*lockfile.Lock, error) + action func(*lockfile.Lock) error + expectedError error + }{ + { + name: "release without acquire", + setup: func() (*lockfile.Lock, error) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + if err != nil { + return nil, err + } + return lockfile.NewLock(f) + }, + action: func(l *lockfile.Lock) error { return l.Release() }, + expectedError: nil, // Release is now idempotent + }, + { + name: "metadata without lock", + setup: func() (*lockfile.Lock, error) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + if err != nil { + return nil, err + } + return lockfile.NewLock(f) + }, + action: func(l *lockfile.Lock) error { + _, err := l.Metadata() + return err + }, + expectedError: errors.New("file does not exist"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + lock, err := tt.setup() + require.NoError(t, err) + + err = tt.action(lock) + if tt.expectedError != nil { + require.ErrorContains(t, err, tt.expectedError.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestWithLock(t *testing.T) { + fs := mem.NewFileSystem() + f, err := fs.NewFile("", "/test.txt") + require.NoError(t, err) + + // Test successful lock acquisition and execution + called := false + err = lockfile.WithLock(f, func(f vfs.File) error { + called = true + return nil + }) + require.NoError(t, err) + require.True(t, called) + + // Test concurrent access + lock, err := lockfile.NewLock(f) + require.NoError(t, err) + err = lock.Acquire() + require.NoError(t, err) + + err = lockfile.WithLock(f, func(f vfs.File) error { + return nil + }) + require.ErrorIs(t, err, lockfile.ErrLockAlreadyHeld) + + err = lock.Release() + require.NoError(t, err) + + // Test error propagation + expectedErr := errors.New("test error") + err = lockfile.WithLock(f, func(f vfs.File) error { + return expectedErr + }) + require.ErrorIs(t, err, expectedErr) +} diff --git a/options/delete/allVersions.go b/options/delete/allVersions.go index ef879efd..98c798ce 100644 --- a/options/delete/allVersions.go +++ b/options/delete/allVersions.go @@ -1,3 +1,4 @@ +// Package delete provides options for deleting files and directories in a virtual filesystem. package delete import "github.com/c2fo/vfs/v7/options" diff --git a/options/newfile/contentType.go b/options/newfile/contentType.go index d659c334..d03e65cb 100644 --- a/options/newfile/contentType.go +++ b/options/newfile/contentType.go @@ -1,3 +1,4 @@ +// Package newfile provides options for creating new files in a virtual filesystem. package newfile import "github.com/c2fo/vfs/v7/options" diff --git a/options/options.go b/options/options.go index 5b0c0dd9..c8eca3f0 100644 --- a/options/options.go +++ b/options/options.go @@ -1,3 +1,4 @@ +// Package options provides a set of interfaces and functions to define and apply options for file systems. package options // Option interface contains function that should be implemented by any custom option. diff --git a/utils/authority/authority.go b/utils/authority/authority.go index 845d6344..d1d03223 100644 --- a/utils/authority/authority.go +++ b/utils/authority/authority.go @@ -1,3 +1,4 @@ +// Package authority provides a struct for parsing and manipulating URI authority strings. package authority import ( @@ -56,7 +57,7 @@ func (u UserInfo) Password() string { return p } -// String() returns a string representation of authority. It does not include password per +// String returns a string representation of authority. It does not include password per // https://tools.ietf.org/html/rfc3986#section-3.2.1 // // Applications should not render as clear text any data after the first colon (":") character found within a userinfo diff --git a/utils/utils.go b/utils/utils.go index 027e0668..83ef476f 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -1,3 +1,4 @@ +// Package utils provides utility functions for working with file paths, URIs, and other common operations. package utils import ( diff --git a/vfssimple/vfssimple.go b/vfssimple/vfssimple.go index a46cb4c7..c72d895e 100644 --- a/vfssimple/vfssimple.go +++ b/vfssimple/vfssimple.go @@ -79,7 +79,7 @@ func parseURI(uri string) (scheme, authority, path string, err error) { authority = fmt.Sprintf("%s@%s", u.User, u.Host) } // network-based schemes require authority, but not file:// or mem:// - if authority == "" && !(scheme == os.Scheme || scheme == mem.Scheme) { + if authority == "" && scheme != os.Scheme && scheme != mem.Scheme { return "", "", "", ErrMissingAuthority }