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
}