Compare commits
90 Commits
4ac80cfcec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01124c10d9 | |||
| 6ba32f5b35 | |||
| e62c709d42 | |||
| 89903fa1cd | |||
| b3d10106e1 | |||
| 9712c10fe3 | |||
| 43916c7746 | |||
| bbab6e73f4 | |||
| 615eecff79 | |||
| 9b67de016d | |||
|
|
3c779465e2 | ||
|
|
5572a4901f | ||
|
|
2adc275278 | ||
|
|
6d9c07510a | ||
| 6d1bdbb00f | |||
| ae70cf6fb5 | |||
| 5099b6951b | |||
| 1f7ee256ec | |||
|
|
28c6fbd220 | ||
|
|
7b61bdd62b | ||
|
|
8c7eef6240 | ||
|
|
97dbe47c32 | ||
|
|
f6478858d7 | ||
| 5aae442156 | |||
| 1f12d10cb7 | |||
| 7f25970dd3 | |||
| 70af055d4e | |||
| 04b05e01e8 | |||
| 7144617d0e | |||
| 2efffd9da8 | |||
| ebaf2a65ca | |||
| 4b80c0067b | |||
| 5ab092098b | |||
| 4a2060087d | |||
| 213364bab5 | |||
| 778999a285 | |||
| 308c583d57 | |||
| 019fe41c3d | |||
| fc0b38ea19 | |||
| 61c17ca585 | |||
| dae6c64e24 | |||
| a5b0343b28 | |||
| e25e309581 | |||
| dc115c5ba2 | |||
| a9f0d2abe4 | |||
| 1588e1bb9f | |||
| 09e8da0855 | |||
| efa4bb929a | |||
| 16e3538ea6 | |||
| 1ae384b6f6 | |||
| b55ae961c8 | |||
| ed40673e85 | |||
| 2549695ab0 | |||
| e480c3f677 | |||
| d3776d7d7c | |||
| 07db5d434f | |||
| 1d37dd9748 | |||
| 1e81801036 | |||
| eaeb84f2cc | |||
| a20c3e5104 | |||
| c218fe56e9 | |||
| 444a4c8f45 | |||
| a07209fef5 | |||
| b3fe38092b | |||
| 9dbdcbde91 | |||
| 6edc798de0 | |||
| 818358a8a1 | |||
| 5523cb1595 | |||
| 0e86562c09 | |||
| 6dc496fa9e | |||
| e6cb906d35 | |||
| 45201509ff | |||
| 21028af9aa | |||
| 150bac82cf | |||
| 92bd13efde | |||
| 531f460f87 | |||
| c5ca3e2ced | |||
| fded1a0393 | |||
| 5d7c729efb | |||
| 48c3c09d85 | |||
| f3be3eba84 | |||
| 5e65b3a0fd | |||
| 79fc5cca6c | |||
| 155ebe9a78 | |||
| dc2ea47f6a | |||
| 2717685619 | |||
| 13f39d598f | |||
| 01bffc8388 | |||
| 7df558d8d0 | |||
| 7a8a1b4a4a |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
*.tmp
|
||||||
|
*.dockerimage
|
||||||
|
.git
|
||||||
14
.drone.yml
14
.drone.yml
@@ -1,14 +0,0 @@
|
|||||||
kind: pipeline
|
|
||||||
name: test-docker-build
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test-docker-build
|
|
||||||
image: plugins/docker
|
|
||||||
network_mode: bridge
|
|
||||||
settings:
|
|
||||||
repo: sneak/mfer
|
|
||||||
dry_run: true
|
|
||||||
custom_dns: [ 116.202.204.30 ]
|
|
||||||
tags:
|
|
||||||
- ${DRONE_COMMIT_SHA}
|
|
||||||
- ${DRONE_BRANCH}
|
|
||||||
9
.gitea/workflows/check.yml
Normal file
9
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
name: check
|
||||||
|
on: [push]
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# actions/checkout v4.2.2, 2026-03-16
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- run: docker build .
|
||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -1,2 +1,13 @@
|
|||||||
src/*.pb.go
|
/bin/
|
||||||
/mfer
|
/tmp
|
||||||
|
*.tmp
|
||||||
|
*.dockerimage
|
||||||
|
/vendor
|
||||||
|
vendor.tzst
|
||||||
|
modcache.tzst
|
||||||
|
|
||||||
|
# Generated manifest files
|
||||||
|
.index.mf
|
||||||
|
|
||||||
|
# Stale files
|
||||||
|
.drone.yml
|
||||||
|
|||||||
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Agent Instructions
|
||||||
|
|
||||||
|
Read `REPO_POLICIES.md` before making any changes. It is the authoritative
|
||||||
|
source for coding standards, formatting, linting, and workflow rules.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
- When fixing a bug, write a failing test FIRST. Only after the test fails,
|
||||||
|
write the code to fix the bug. Then ensure the test passes. Leave the test in
|
||||||
|
place and commit it with the bugfix. Don't run shell commands to test bugfixes
|
||||||
|
or reproduce bugs. Write tests!
|
||||||
|
|
||||||
|
- After each change, run `make fmt`, then `make test`, then `make lint`. Fix any
|
||||||
|
failures before committing.
|
||||||
|
|
||||||
|
- After each change, commit only the files you've changed. Push after committing.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
- Never mention Claude, Anthropic, or any AI/LLM tooling in commit messages. Do
|
||||||
|
not use attribution.
|
||||||
|
|
||||||
|
## Repository-Specific Notes
|
||||||
|
|
||||||
|
- This is a Go library + CLI tool for generating `.mf` manifest files.
|
||||||
|
- The proto definition is in `mfer/mf.proto`; generated `.pb.go` files are
|
||||||
|
committed (required for `go get` compatibility).
|
||||||
|
- The format specification is in `FORMAT.md`.
|
||||||
|
- See `TODO.md` for the 1.0 implementation plan and open design questions.
|
||||||
58
Dockerfile
58
Dockerfile
@@ -1,42 +1,38 @@
|
|||||||
## lint image
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
## current as of 2022-01-25
|
# golangci/golangci-lint:v2.0.2 (2026-03-14)
|
||||||
FROM golangci/golangci-lint@sha256:d16ef91da7e10f3df45c36876543326abbc4c16aaab6548549560b9f52e9e831 AS linter
|
FROM golangci/golangci-lint@sha256:d55581f7797e7a0877a7c3aaa399b01bdc57d2874d6412601a046cc4062cb62e AS lint
|
||||||
|
|
||||||
RUN mkdir -p /build
|
WORKDIR /src
|
||||||
WORKDIR /build
|
COPY go.mod go.sum ./
|
||||||
COPY ./ ./
|
RUN go mod download
|
||||||
RUN golangci-lint run
|
|
||||||
|
|
||||||
## build image:
|
COPY . .
|
||||||
# this is golang:1.17.6-buster as of 2022-01-27
|
|
||||||
FROM golang@sha256:52a48e0239f4d645b20ac268a60361703afe7feb2df5697fa89f72052cb87a3e AS builder
|
|
||||||
#FROM golang:1.16-buster AS builder
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND noninteractive
|
# Touch .pb.go so make does not try to regenerate via protoc (file is committed)
|
||||||
RUN apt update && apt install -y make bzip2 curl unzip
|
RUN touch mfer/mf.pb.go
|
||||||
|
|
||||||
# install newer protoc than what comes with buster
|
RUN make fmt-check
|
||||||
ENV PB_REL https://github.com/protocolbuffers/protobuf/releases
|
RUN make lint
|
||||||
RUN curl -LO $PB_REL/download/v3.19.0/protoc-3.19.0-linux-x86_64.zip && \
|
|
||||||
unzip protoc-3.19.0-linux-x86_64.zip -d /usr/local
|
|
||||||
|
|
||||||
RUN mkdir -p /build
|
# Build stage — tests and compilation
|
||||||
WORKDIR /build
|
# golang:1.23 (2026-03-14)
|
||||||
|
FROM golang@sha256:60deed95d3888cc5e4d9ff8a10c54e5edc008c6ae3fba6187be6fb592e19e8c0 AS builder
|
||||||
|
|
||||||
COPY go.mod .
|
# Force BuildKit to run the lint stage by creating a stage dependency
|
||||||
COPY go.sum .
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
COPY ./ ./
|
WORKDIR /src
|
||||||
# don't lint again during build because there's no golangci-lint in this
|
COPY go.mod go.sum ./
|
||||||
# image and we already did it in a previous stage
|
RUN go mod download
|
||||||
#RUN make lint
|
|
||||||
RUN make mfer
|
|
||||||
#RUN go mod vendor
|
|
||||||
RUN tar -c . | bzip2 > /src.tbz2
|
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Touch .pb.go so make does not try to regenerate via protoc (file is committed)
|
||||||
|
RUN touch mfer/mf.pb.go
|
||||||
|
|
||||||
|
RUN make test
|
||||||
|
RUN cd cmd/mfer && go build -tags urfave_cli_no_docs -o /mfer .
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
COPY --from=builder /src.tbz2 /src.tbz2
|
COPY --from=builder /mfer /mfer
|
||||||
COPY --from=builder /build/mfer /mfer
|
|
||||||
ENTRYPOINT ["/mfer"]
|
ENTRYPOINT ["/mfer"]
|
||||||
|
|
||||||
|
|||||||
143
FORMAT.md
Normal file
143
FORMAT.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# .mf File Format Specification
|
||||||
|
|
||||||
|
Version 1.0
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
An `.mf` file is a binary manifest that describes a directory tree of files,
|
||||||
|
including their paths, sizes, and cryptographic checksums. It supports
|
||||||
|
optional GPG signatures for integrity verification and optional timestamps
|
||||||
|
for metadata preservation.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
An `.mf` file consists of two parts, concatenated:
|
||||||
|
|
||||||
|
1. **Magic bytes** (8 bytes): the ASCII string `ZNAVSRFG`
|
||||||
|
2. **Outer message**: a Protocol Buffers serialized `MFFileOuter` message
|
||||||
|
|
||||||
|
There is no length prefix or version byte between the magic and the protobuf
|
||||||
|
message. The protobuf message extends to the end of the file.
|
||||||
|
|
||||||
|
See [`mfer/mf.proto`](mfer/mf.proto) for exact field numbers and types.
|
||||||
|
|
||||||
|
## Outer Message (`MFFileOuter`)
|
||||||
|
|
||||||
|
The outer message contains:
|
||||||
|
|
||||||
|
| Field | Number | Type | Description |
|
||||||
|
| ----------------- | ------ | ---------------- | ------------------------------------------------------------------------ |
|
||||||
|
| `version` | 101 | enum | Must be `VERSION_ONE` (1) |
|
||||||
|
| `compressionType` | 102 | enum | Compression of `innerMessage`; must be `COMPRESSION_ZSTD` (1) |
|
||||||
|
| `size` | 103 | int64 | Uncompressed size of `innerMessage` (corruption detection) |
|
||||||
|
| `sha256` | 104 | bytes | SHA-256 hash of the **compressed** `innerMessage` (corruption detection) |
|
||||||
|
| `uuid` | 105 | bytes | Random v4 UUID; must match the inner message UUID |
|
||||||
|
| `innerMessage` | 199 | bytes | Zstd-compressed serialized `MFFile` message |
|
||||||
|
| `signature` | 201 | bytes (optional) | GPG signature (ASCII-armored or binary) |
|
||||||
|
| `signer` | 202 | bytes (optional) | Full GPG key ID of the signer |
|
||||||
|
| `signingPubKey` | 203 | bytes (optional) | Full GPG signing public key |
|
||||||
|
|
||||||
|
### SHA-256 Hash
|
||||||
|
|
||||||
|
The `sha256` field (104) covers the **compressed** `innerMessage` bytes.
|
||||||
|
This allows verifying data integrity before decompression.
|
||||||
|
|
||||||
|
## Compression
|
||||||
|
|
||||||
|
The `innerMessage` field is compressed with [Zstandard (zstd)](https://facebook.github.io/zstd/).
|
||||||
|
Implementations must enforce a decompression size limit to prevent
|
||||||
|
decompression bombs. The reference implementation limits decompressed size to
|
||||||
|
256 MB.
|
||||||
|
|
||||||
|
## Inner Message (`MFFile`)
|
||||||
|
|
||||||
|
After decompressing `innerMessage`, the result is a serialized `MFFile`
|
||||||
|
(referred to as the manifest):
|
||||||
|
|
||||||
|
| Field | Number | Type | Description |
|
||||||
|
| ----------- | ------ | --------------------- | ------------------------------------- |
|
||||||
|
| `version` | 100 | enum | Must be `VERSION_ONE` (1) |
|
||||||
|
| `files` | 101 | repeated `MFFilePath` | List of files in the manifest |
|
||||||
|
| `uuid` | 102 | bytes | Random v4 UUID; must match outer UUID |
|
||||||
|
| `createdAt` | 201 | Timestamp (optional) | When the manifest was created |
|
||||||
|
|
||||||
|
## File Entries (`MFFilePath`)
|
||||||
|
|
||||||
|
Each file entry contains:
|
||||||
|
|
||||||
|
| Field | Number | Type | Description |
|
||||||
|
| ---------- | ------ | ------------------------- | ----------------------------------- |
|
||||||
|
| `path` | 1 | string | Relative file path (see Path Rules) |
|
||||||
|
| `size` | 2 | int64 | File size in bytes |
|
||||||
|
| `hashes` | 3 | repeated `MFFileChecksum` | At least one hash required |
|
||||||
|
| `mimeType` | 301 | string (optional) | MIME type |
|
||||||
|
| `mtime` | 302 | Timestamp (optional) | Modification time |
|
||||||
|
| `ctime` | 303 | Timestamp (optional) | Change time (inode metadata change) |
|
||||||
|
|
||||||
|
Field 304 (`atime`) has been removed from the specification. Access time is
|
||||||
|
volatile and non-deterministic; it is not useful for integrity verification.
|
||||||
|
|
||||||
|
## Path Rules
|
||||||
|
|
||||||
|
All `path` values must satisfy these invariants:
|
||||||
|
|
||||||
|
- **UTF-8**: paths must be valid UTF-8
|
||||||
|
- **Forward slashes**: use `/` as the path separator (never `\`)
|
||||||
|
- **Relative only**: no leading `/`
|
||||||
|
- **No parent traversal**: no `..` path segments
|
||||||
|
- **No empty segments**: no `//` sequences
|
||||||
|
- **No trailing slash**: paths refer to files, not directories
|
||||||
|
|
||||||
|
Implementations must validate these invariants when reading and writing
|
||||||
|
manifests. Paths that violate these rules must be rejected.
|
||||||
|
|
||||||
|
## Hash Format (`MFFileChecksum`)
|
||||||
|
|
||||||
|
Each checksum is a single `bytes multiHash` field containing a
|
||||||
|
[multihash](https://multiformats.io/multihash/)-encoded value. Multihash is
|
||||||
|
self-describing: the encoded bytes include a varint algorithm identifier
|
||||||
|
followed by a varint digest length followed by the digest itself.
|
||||||
|
|
||||||
|
The 1.0 implementation writes SHA-256 multihashes (`0x12` algorithm code).
|
||||||
|
Implementations must be able to verify SHA-256 multihashes at minimum.
|
||||||
|
|
||||||
|
## Signature Scheme
|
||||||
|
|
||||||
|
Signing is optional. When present, the signature covers a canonical string
|
||||||
|
constructed as:
|
||||||
|
|
||||||
|
```
|
||||||
|
ZNAVSRFG-<UUID>-<SHA256>
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
- `ZNAVSRFG` is the magic bytes string (literal ASCII)
|
||||||
|
- `<UUID>` is the hex-encoded UUID from the outer message
|
||||||
|
- `<SHA256>` is the hex-encoded SHA-256 hash from the outer message (covering compressed data)
|
||||||
|
|
||||||
|
Components are separated by hyphens. The signature is produced by GPG over
|
||||||
|
this canonical string and stored in the `signature` field of the outer
|
||||||
|
message.
|
||||||
|
|
||||||
|
## Deterministic Serialization
|
||||||
|
|
||||||
|
By default, manifests are generated deterministically:
|
||||||
|
|
||||||
|
- File entries are sorted by `path` in **lexicographic byte order**
|
||||||
|
- `createdAt` is omitted unless explicitly requested
|
||||||
|
- `atime` is never included (field removed from schema)
|
||||||
|
|
||||||
|
This ensures that two independent runs over the same directory tree produce
|
||||||
|
byte-identical `.mf` files (assuming file contents and metadata have not
|
||||||
|
changed).
|
||||||
|
|
||||||
|
## MIME Type
|
||||||
|
|
||||||
|
The recommended MIME type for `.mf` files is `application/octet-stream`.
|
||||||
|
The `.mf` file extension is the canonical identifier.
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- Proto definition: [`mfer/mf.proto`](mfer/mf.proto)
|
||||||
|
- Reference implementation: [git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer)
|
||||||
84
Makefile
84
Makefile
@@ -1,44 +1,90 @@
|
|||||||
|
export DOCKER_BUILDKIT := 1
|
||||||
|
export PROGRESS_NO_TRUNC := 1
|
||||||
GOPATH := $(shell go env GOPATH)
|
GOPATH := $(shell go env GOPATH)
|
||||||
export PATH := $(PATH):$(GOPATH)/bin
|
export PATH := $(PATH):$(GOPATH)/bin
|
||||||
PROTOC_GEN_GO := $(GOPATH)/bin/protoc-gen-go
|
PROTOC_GEN_GO := $(GOPATH)/bin/protoc-gen-go
|
||||||
|
SOURCEFILES := mfer/*.go mfer/*.proto internal/*/*.go cmd/*/*.go go.mod go.sum
|
||||||
ARCH := $(shell uname -m)
|
ARCH := $(shell uname -m)
|
||||||
VERSION := $(shell git describe --always --dirty=-dirty)
|
GITREV_BUILD := $(shell bash $(PWD)/bin/gitrev.sh 2>/dev/null || echo unknown)
|
||||||
|
APPNAME := mfer
|
||||||
|
VERSION := 0.1.0
|
||||||
|
export DOCKER_IMAGE_CACHE_DIR := $(HOME)/Library/Caches/Docker/$(APPNAME)-$(ARCH)
|
||||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||||
GOLDFLAGS += -X main.Buildarch=$(ARCH)
|
GOLDFLAGS += -X main.Gitrev=$(GITREV_BUILD)
|
||||||
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
GOFLAGS := -ldflags "$(GOLDFLAGS)"
|
||||||
|
|
||||||
default: run
|
.PHONY: docker default run ci test check lint fmt fmt-check hooks fixme
|
||||||
|
|
||||||
run: ./mfer
|
default: fmt test
|
||||||
|
|
||||||
|
run: ./bin/mfer
|
||||||
./$<
|
./$<
|
||||||
./$< gen
|
./$< gen
|
||||||
|
|
||||||
|
ci: test
|
||||||
|
|
||||||
|
test: $(SOURCEFILES) mfer/mf.pb.go
|
||||||
|
go test -v --timeout 10s ./...
|
||||||
|
|
||||||
$(PROTOC_GEN_GO):
|
$(PROTOC_GEN_GO):
|
||||||
test -e $(PROTOC_GEN_GO) || go install -v google.golang.org/protobuf/cmd/protoc-gen-go@latest
|
test -e $(PROTOC_GEN_GO) || go install -v google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.1
|
||||||
|
|
||||||
|
fixme:
|
||||||
|
@grep -nir fixme . | grep -v Makefile
|
||||||
|
|
||||||
|
check: test lint fmt-check
|
||||||
|
|
||||||
|
fmt-check: mfer/mf.pb.go
|
||||||
|
sh -c 'test -z "$$(gofmt -l .)"'
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
echo '#!/bin/sh\nmake check' > .git/hooks/pre-commit
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
|
||||||
devprereqs:
|
devprereqs:
|
||||||
which gofumpt || go install -v mvdan.cc/gofumpt@latest
|
which golangci-lint || go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@v2.0.2
|
||||||
which golangci-lint || go install -v github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
|
||||||
|
|
||||||
mfer: $(PROTOC_GEN_GO) src/*.go cmd/*/*.go
|
mfer/mf.pb.go: mfer/mf.proto
|
||||||
|
cd mfer && go generate .
|
||||||
|
|
||||||
|
bin/mfer: $(SOURCEFILES) mfer/mf.pb.go
|
||||||
protoc --version
|
protoc --version
|
||||||
cd src && go generate .
|
cd cmd/mfer && go build -tags urfave_cli_no_docs -o ../../bin/mfer $(GOFLAGS) .
|
||||||
cd cmd/mfer && go build -o ../../mfer $(GOFLAGS) .
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rfv src/*.pb.go ./mfer
|
rm -rfv mfer/*.pb.go bin/mfer cmd/mfer/mfer *.dockerimage
|
||||||
|
|
||||||
fmt: prereqs
|
fmt: mfer/mf.pb.go
|
||||||
gofumpt -l -w src cmd
|
gofumpt -l -w mfer internal cmd
|
||||||
golangci-lint run --fix
|
golangci-lint run --fix
|
||||||
prettier -w *.json *.md
|
-prettier -w *.json
|
||||||
|
-prettier -w *.md
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
sh -c 'test -z "$$(gofmt -l .)"'
|
sh -c 'test -z "$$(gofmt -l .)"'
|
||||||
|
|
||||||
dockerbuild:
|
docker: sneak-mfer.$(ARCH).tzst.dockerimage
|
||||||
docker build .
|
|
||||||
|
sneak-mfer.$(ARCH).tzst.dockerimage: $(SOURCEFILES) vendor.tzst modcache.tzst
|
||||||
|
docker build --progress plain --build-arg GITREV=$(GITREV_BUILD) -t sneak/mfer .
|
||||||
|
docker save sneak/mfer | pv | zstdmt -19 > $@
|
||||||
|
du -sh $@
|
||||||
|
|
||||||
|
godoc:
|
||||||
|
open http://127.0.0.1:6060
|
||||||
|
godoc -http=:6060
|
||||||
|
|
||||||
|
vendor.tzst: go.mod go.sum
|
||||||
|
go mod tidy
|
||||||
|
go mod vendor
|
||||||
|
cd vendor && tar -c . | pv | zstdmt -19 > $(PWD)/$@.tmp
|
||||||
|
rm -rf vendor
|
||||||
|
mv $@.tmp $@
|
||||||
|
|
||||||
|
modcache.tzst: go.mod go.sum
|
||||||
|
go mod tidy
|
||||||
|
cd $(HOME)/go/pkg && chmod -R u+rw . && rm -rf mod sumdb
|
||||||
|
go mod download -x
|
||||||
|
cd $(shell go env GOMODCACHE) && tar -c . | pv | zstdmt -19 > $(PWD)/$@.tmp
|
||||||
|
mv $@.tmp $@
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -1,11 +1,50 @@
|
|||||||
# mfer
|
# mfer
|
||||||
|
|
||||||
Manifest file generator and checker.
|
[mfer](https://git.eeqj.de/sneak/mfer) is a reference implementation library
|
||||||
|
and thin wrapper command-line utility written in [Go](https://golang.org)
|
||||||
|
and first published in 2022 under the [WTFPL](https://wtfpl.net) (public
|
||||||
|
domain) license. It specifies and generates `.mf` manifest files over a
|
||||||
|
directory tree of files to encapsulate metadata about them (such as
|
||||||
|
cryptographic checksums or signatures over same) to aid in archiving,
|
||||||
|
downloading, and streaming, or mirroring. The manifest files' data is
|
||||||
|
serialized with Google's [protobuf serialization
|
||||||
|
format](https://developers.google.com/protocol-buffers). The structure of
|
||||||
|
these files can be found [in the format
|
||||||
|
specification](https://git.eeqj.de/sneak/mfer/src/branch/main/mfer/mf.proto)
|
||||||
|
which is included in the [project
|
||||||
|
repository](https://git.eeqj.de/sneak/mfer).
|
||||||
|
|
||||||
|
The current version is pre-1.0 and while the repo was published in 2022,
|
||||||
|
there has not yet been any versioned release. [SemVer](https://semver.org)
|
||||||
|
will be used for releases.
|
||||||
|
|
||||||
|
This project was started by [@sneak](https://sneak.berlin) to scratch an
|
||||||
|
itch in 2022 and is currently a one-person effort, though the goal is for
|
||||||
|
this to emerge as a de-facto standard and be incorporated into other
|
||||||
|
software. A compatible javascript library is planned.
|
||||||
|
|
||||||
# Build Status
|
# Build Status
|
||||||
|
|
||||||
[](https://drone.datavi.be/sneak/mfer)
|
[](https://drone.datavi.be/sneak/mfer)
|
||||||
|
|
||||||
|
# Participation
|
||||||
|
|
||||||
|
The community is as yet nonexistent so there are no defined policies or
|
||||||
|
norms yet. Primary development happens on a privately-run Gitea instance at
|
||||||
|
[https://git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer) and issues
|
||||||
|
are [tracked there](https://git.eeqj.de/sneak/mfer/issues).
|
||||||
|
|
||||||
|
Changes must always be formatted with a standard `go fmt`, syntactically
|
||||||
|
valid, and must pass the linting defined in the repository (presently only
|
||||||
|
the `golangci-lint` defaults), which can be run with a `make lint`. The
|
||||||
|
`main` branch is protected and all changes must be made via [pull
|
||||||
|
requests](https://git.eeqj.de/sneak/mfer/pulls) and pass CI to be merged.
|
||||||
|
Any changes submitted to this project must also be
|
||||||
|
[WTFPL-licensed](https://wtfpl.net) to be considered.
|
||||||
|
|
||||||
|
See [`REPO_POLICIES.md`](REPO_POLICIES.md) for detailed coding standards,
|
||||||
|
tooling requirements, and workflow conventions.
|
||||||
|
|
||||||
# Problem Statement
|
# Problem Statement
|
||||||
|
|
||||||
Given a plain URL, there is no standard way to safely and programmatically
|
Given a plain URL, there is no standard way to safely and programmatically
|
||||||
@@ -86,7 +125,6 @@ The manifest file would do several important things:
|
|||||||
# Open Questions
|
# Open Questions
|
||||||
|
|
||||||
- Should the manifest file include checksums of individual file chunks, or just for the whole assembled file?
|
- Should the manifest file include checksums of individual file chunks, or just for the whole assembled file?
|
||||||
|
|
||||||
- If so, should the chunksize be fixed or dynamic?
|
- If so, should the chunksize be fixed or dynamic?
|
||||||
|
|
||||||
- Should the manifest signature format be GnuPG signatures, or those from
|
- Should the manifest signature format be GnuPG signatures, or those from
|
||||||
@@ -128,8 +166,7 @@ The manifest file would do several important things:
|
|||||||
- a command line option to zero/omit mtime/ctime, as well as manifest
|
- a command line option to zero/omit mtime/ctime, as well as manifest
|
||||||
timestamp, and sort all directory listings so that manifest file
|
timestamp, and sort all directory listings so that manifest file
|
||||||
generation is deterministic/reproducible
|
generation is deterministic/reproducible
|
||||||
- URL format `mfer fetch
|
- URL format `mfer fetch https://exmaple.com/manifestdirectory/?key=5539AD00DE4C42F3AFE11575052443F4DF2A55C2`
|
||||||
https://exmaple.com/manifestdirectory/?key=5539AD00DE4C42F3AFE11575052443F4DF2A55C2`
|
|
||||||
to assert in the URL which PGP signing key should be used in the manifest,
|
to assert in the URL which PGP signing key should be used in the manifest,
|
||||||
so that shared URLs have a cryptographic trust root
|
so that shared URLs have a cryptographic trust root
|
||||||
- a "well-known" key in the manifest that maps well known keys (could reuse
|
- a "well-known" key in the manifest that maps well known keys (could reuse
|
||||||
@@ -171,6 +208,24 @@ regardless of filesystem format.
|
|||||||
Please email [`sneak@sneak.berlin`](mailto:sneak@sneak.berlin) with your
|
Please email [`sneak@sneak.berlin`](mailto:sneak@sneak.berlin) with your
|
||||||
desired username for an account on this Gitea instance.
|
desired username for an account on this Gitea instance.
|
||||||
|
|
||||||
I am currently interested in hiring a contractor skilled with the Go
|
# See Also
|
||||||
standard library interfaces to specify this tool in full and develop a
|
|
||||||
prototype implementation.
|
## Prior Art: Metalink
|
||||||
|
|
||||||
|
- [Metalink - Mozilla Wiki](https://wiki.mozilla.org/Metalink)
|
||||||
|
- [Metalink - Wikipedia](https://en.wikipedia.org/wiki/Metalink)
|
||||||
|
- [RFC 5854 - The Metalink Download Description Format](https://datatracker.ietf.org/doc/html/rfc5854)
|
||||||
|
- [RFC 6249 - Metalink/HTTP: Mirrors and Hashes](https://www.rfc-editor.org/rfc/rfc6249.html)
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Repo: [https://git.eeqj.de/sneak/mfer](https://git.eeqj.de/sneak/mfer)
|
||||||
|
- Issues: [https://git.eeqj.de/sneak/mfer/issues](https://git.eeqj.de/sneak/mfer/issues)
|
||||||
|
|
||||||
|
# Authors
|
||||||
|
|
||||||
|
- [@sneak <sneak@sneak.berlin>](mailto:sneak@sneak.berlin)
|
||||||
|
|
||||||
|
# License
|
||||||
|
|
||||||
|
- [WTFPL](https://wtfpl.net)
|
||||||
|
|||||||
255
REPO_POLICIES.md
Normal file
255
REPO_POLICIES.md
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
---
|
||||||
|
title: Repository Policies
|
||||||
|
last_modified: 2026-03-10
|
||||||
|
---
|
||||||
|
|
||||||
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
|
style conventions are in separate documents:
|
||||||
|
|
||||||
|
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||||
|
(general, bash, Docker)
|
||||||
|
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||||
|
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||||
|
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||||
|
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Cross-project documentation (such as this file) must include
|
||||||
|
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
|
||||||
|
with the authoritative source as policies evolve.
|
||||||
|
|
||||||
|
- **ALL external references must be pinned by cryptographic hash.** This
|
||||||
|
includes Docker base images, Go modules, npm packages, GitHub Actions, and
|
||||||
|
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
|
||||||
|
`:3.21`, etc.) are server-mutable and therefore remote code execution
|
||||||
|
vulnerabilities. The ONLY acceptable way to reference an external dependency
|
||||||
|
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
|
||||||
|
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
|
||||||
|
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
|
||||||
|
etc. Instead, download a specific release archive from GitHub, verify its hash
|
||||||
|
(hardcoded in the Dockerfile or script), and only then install. Unverified
|
||||||
|
install scripts are arbitrary remote code execution. This is the single most
|
||||||
|
important rule in this document. Double-check every external reference in
|
||||||
|
every file before committing. There are zero exceptions to this rule.
|
||||||
|
|
||||||
|
- Every repo with software must have a root `Makefile` with these targets:
|
||||||
|
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
|
||||||
|
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
|
||||||
|
`make hooks` (installs pre-commit hook). A model Makefile is at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
|
||||||
|
instead of invoking the underlying tools directly. The Makefile is the single
|
||||||
|
source of truth for how these operations are run.
|
||||||
|
|
||||||
|
- The Makefile is authoritative documentation for how the repo is used. Beyond
|
||||||
|
the required targets above, it should have targets for every common operation:
|
||||||
|
running a local development server (`make run`, `make dev`), re-initializing
|
||||||
|
or migrating the database (`make db-reset`, `make migrate`), building
|
||||||
|
artifacts (`make build`), generating code, seeding data, or anything else a
|
||||||
|
developer would do regularly. If someone checks out the repo and types
|
||||||
|
`make<tab>`, they should see every meaningful operation available. A new
|
||||||
|
contributor should be able to understand the entire development workflow by
|
||||||
|
reading the Makefile.
|
||||||
|
|
||||||
|
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||||
|
as a build step so the build fails if the branch is not green. For non-server
|
||||||
|
repos, the Dockerfile should bring up a development environment and run
|
||||||
|
`make check`. For server repos, `make check` should run as an early build
|
||||||
|
stage before the final image is assembled.
|
||||||
|
|
||||||
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
|
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
|
||||||
|
a successful build implies all checks pass.
|
||||||
|
|
||||||
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||||
|
two exceptions: four-space indents (except Go), and `proseWrap: always` for
|
||||||
|
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
|
||||||
|
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
|
- Pre-commit hook: `make check` if local testing is possible, otherwise
|
||||||
|
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
|
||||||
|
target to install the pre-commit hook.
|
||||||
|
|
||||||
|
- All repos with software must have tests that run via the platform-standard
|
||||||
|
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
|
||||||
|
tests exist yet, add the most minimal test possible — e.g. importing the
|
||||||
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
|
`make test` to be a no-op.
|
||||||
|
|
||||||
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
|
Makefile.
|
||||||
|
|
||||||
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
|
- `make check` must not modify any files in the repo. Tests may use temporary
|
||||||
|
directories.
|
||||||
|
|
||||||
|
- `main` must always pass `make check`, no exceptions.
|
||||||
|
|
||||||
|
- Never commit secrets. `.env` files, credentials, API keys, and private keys
|
||||||
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
|
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||||
|
Fetch the standard `.gitignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||||
|
a new repo.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to the
|
||||||
|
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
|
||||||
|
should generate these at build time. Notable exception: Go protobuf generated
|
||||||
|
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
|
||||||
|
downloads code but does not execute code generation.
|
||||||
|
|
||||||
|
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
|
||||||
|
|
||||||
|
- Never force-push to `main`.
|
||||||
|
|
||||||
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
|
feature branch.
|
||||||
|
|
||||||
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
|
manually by the user. Fetch from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||||
|
|
||||||
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
|
with the version and date (YYYY-MM-DD).
|
||||||
|
|
||||||
|
- Use `yarn`, not `npm`.
|
||||||
|
|
||||||
|
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||||
|
|
||||||
|
- Simple projects should be configured with environment variables.
|
||||||
|
|
||||||
|
- Dockerized web services listen on port 8080 by default, overridable with
|
||||||
|
`PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure before
|
||||||
|
tagging 1.0.** This means full compliance with security best practices
|
||||||
|
including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
|
||||||
|
and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default policy
|
||||||
|
(`default-src 'self'` as a baseline, tightened per-resource as
|
||||||
|
needed). Never use `unsafe-inline` or `unsafe-eval` unless
|
||||||
|
unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
|
||||||
|
Prefer the `frame-ancestors` CSP directive as the primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features the
|
||||||
|
application does not use (camera, microphone, geolocation, etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never accept
|
||||||
|
unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
|
||||||
|
against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API keys are
|
||||||
|
high-entropy and not susceptible to brute force, so they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API key) are
|
||||||
|
exempt because the browser does not attach these automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
|
||||||
|
MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
|
||||||
|
`Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
|
||||||
|
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
|
||||||
|
only for public, unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries, file paths,
|
||||||
|
or other implementation details to the client. Return generic error
|
||||||
|
messages in production; detailed errors only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always deployed behind
|
||||||
|
a TLS-terminating reverse proxy. The service itself listens on plain
|
||||||
|
HTTP. However, HSTS headers and `Secure` cookie flags must still be
|
||||||
|
set by the application so that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard security
|
||||||
|
hardening measure exists for HTTP services and is not listed here, it is
|
||||||
|
still expected. When in doubt, harden.
|
||||||
|
|
||||||
|
- `README.md` is the primary documentation. Required sections:
|
||||||
|
- **Description**: First line must include the project name, purpose,
|
||||||
|
category (web server, SPA, CLI tool, etc.), license, and author. Example:
|
||||||
|
"µPaaS is an MIT-licensed Go web application by @sneak that receives
|
||||||
|
git-frontend webhooks and deploys applications via Docker in realtime."
|
||||||
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Rationale**: Why does this exist?
|
||||||
|
- **Design**: How is the program structured?
|
||||||
|
- **TODO**: Update meticulously, even between commits. When planning, put
|
||||||
|
the todo list in the README so a new agent can pick up where the last one
|
||||||
|
left off.
|
||||||
|
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
|
||||||
|
`LICENSE` file in the repo root and a License section in the README.
|
||||||
|
- **Author**: [@sneak](https://sneak.berlin).
|
||||||
|
|
||||||
|
- First commit of a new repo should contain only `README.md`.
|
||||||
|
|
||||||
|
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
|
||||||
|
committing.
|
||||||
|
|
||||||
|
- Use SemVer.
|
||||||
|
|
||||||
|
- Database migrations live in `internal/db/migrations/` and must be embedded in
|
||||||
|
the binary.
|
||||||
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
|
||||||
|
There is no installed base to migrate. Edit `001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema change.
|
||||||
|
Never edit existing migrations after release.
|
||||||
|
|
||||||
|
- All repos should have an `.editorconfig` enforcing the project's indentation
|
||||||
|
settings.
|
||||||
|
|
||||||
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
|
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||||
|
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||||
|
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||||
|
subdirectory names:
|
||||||
|
- `bin/` — executable scripts and tools
|
||||||
|
- `cmd/` — Go command entrypoints
|
||||||
|
- `configs/` — configuration templates and examples
|
||||||
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
|
- `docs/` — documentation and markdown (README.md stays in root)
|
||||||
|
- `internal/` — Go internal packages
|
||||||
|
- `internal/db/migrations/` — database migrations
|
||||||
|
- `pkg/` — Go library packages
|
||||||
|
- `share/` — systemd units, data files
|
||||||
|
- `static/` — static assets (images, fonts, etc.)
|
||||||
|
- `web/` — web frontend source
|
||||||
|
|
||||||
|
- When setting up a new repo, files from the `prompts` repo may be used as
|
||||||
|
templates. Fetch them from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||||
|
|
||||||
|
- New repos must contain at minimum:
|
||||||
|
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
|
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
|
- `Makefile`
|
||||||
|
- `Dockerfile`, `.dockerignore`
|
||||||
|
- `.gitea/workflows/check.yml`
|
||||||
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
|
- Python: `pyproject.toml`
|
||||||
122
TODO.md
Normal file
122
TODO.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# TODO: mfer 1.0
|
||||||
|
|
||||||
|
## Design Questions
|
||||||
|
|
||||||
|
_sneak: please answer inline below each question. These are preserved for posterity._
|
||||||
|
|
||||||
|
### Format Design
|
||||||
|
|
||||||
|
**1. Should `MFFileChecksum` be simplified?**
|
||||||
|
Currently it's a separate message wrapping a single `bytes multiHash` field. Since multihash already self-describes the algorithm, `repeated bytes hashes` directly on `MFFilePath` would be simpler and reduce per-file protobuf overhead. Is the extra message layer intentional (e.g. planning to add per-hash metadata like `verified_at`)?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**2. Should file permissions/mode be stored?**
|
||||||
|
The format stores mtime/ctime but not Unix file permissions. For archival use (ExFAT, filesystem-independent checksums) this may not matter, but for software distribution or filesystem restoration it's a gap. Should we reserve a field now (e.g. `optional uint32 mode = 305`) even if we don't populate it yet?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**3. Should `atime` be removed from the schema?**
|
||||||
|
Access time is volatile, non-deterministic, and often disabled (`noatime`). Including it means two manifests of the same directory at different times will differ, which conflicts with the determinism goal. Remove it, or document it as "never set by default"?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**4. What are the path normalization rules?**
|
||||||
|
The proto has `string path` with no specification about: always forward-slash? Must be relative? No `..` components allowed? UTF-8 NFC vs NFD normalization (macOS vs Linux)? Max path length? This is a security issue (path traversal) and a cross-platform compatibility issue. What rules should the spec mandate?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**5. Should we add a version byte after the magic?**
|
||||||
|
Currently `ZNAVSRFG` is followed immediately by protobuf. Adding a version byte (`ZNAVSRFG\x01`) would allow future framing changes without requiring protobuf parsing to detect the version. `MFFileOuter.Version` serves this purpose but requires successful deserialization to read. Worth the extra byte?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**6. Should we add a length-prefix after the magic?**
|
||||||
|
Protobuf is not self-delimiting. If we ever want to concatenate manifests or append data after the protobuf, the current framing is insufficient. Add a varint or fixed-width length-prefix?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
### Signature Design
|
||||||
|
|
||||||
|
**7. What does the outer SHA-256 hash cover — compressed or uncompressed data?**
|
||||||
|
The review notes it currently hashes compressed data (good for verifying before decompression), but this should be explicitly documented. Which is the intended behavior?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**8. Should `signatureString()` sign raw bytes instead of a hex-encoded string?**
|
||||||
|
Currently the canonical string is `MAGIC-UUID-MULTIHASH` with hex encoding, which adds a transformation layer. Signing the raw `sha256` bytes (or compressed `innerMessage` directly) would be simpler. Keep the string format or switch to raw bytes?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**9. Should we support detached signature files (`.mf.sig`)?**
|
||||||
|
Embedded signatures are better for single-file distribution. Detached `.mf.sig` files follow the familiar `SHASUMS`/`SHASUMS.asc` pattern and are simpler for HTTP serving. Support both modes?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**10. GPG vs pure-Go crypto for signatures?**
|
||||||
|
Shelling out to `gpg` is fragile (may not be installed, version-dependent output). `github.com/ProtonMail/go-crypto` provides pure-Go OpenPGP, or we could go Ed25519/signify (simpler, no key management). Which direction?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
### Implementation Design
|
||||||
|
|
||||||
|
**11. Should manifests be deterministic by default?**
|
||||||
|
This means: sort file entries by path, omit `createdAt` timestamp (or make it opt-in), no `atime`. Should determinism be the default, with a `--include-timestamps` flag to opt in?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**12. Should we consolidate or keep both scanner/checker implementations?**
|
||||||
|
There are two parallel implementations: `mfer/scanner.go` + `mfer/checker.go` (typed with `FileSize`, `RelFilePath`) and `internal/scanner/` + `internal/checker/` (raw `int64`, `string`). The `mfer/` versions are superior. Delete the `internal/` versions?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**13. Should the `manifest` type be exported?**
|
||||||
|
Currently unexported with exported constructors (`New`, `NewFromPaths`, etc.). Consumers can't declare `var m *mfer.manifest`. Export the type, or define an interface?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
**14. What should the Go module path be for 1.0?**
|
||||||
|
Currently mixed between `sneak.berlin/go/mfer` and `git.eeqj.de/sneak/mfer`. Which is canonical?
|
||||||
|
|
||||||
|
> _answer:_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Foundation (format correctness)
|
||||||
|
|
||||||
|
- [ ] Delete `internal/scanner/` and `internal/checker/` — consolidate on `mfer/` package versions; update CLI code
|
||||||
|
- [ ] Add deterministic file ordering — sort entries by path (lexicographic, byte-order) in `Builder.Build()`; add test asserting byte-identical output from two runs
|
||||||
|
- [ ] Add decompression size limit — `io.LimitReader` in `deserializeInner()` with `m.pbOuter.Size` as bound
|
||||||
|
- [ ] Fix `errors.Is` dead code in checker — replace with `os.IsNotExist(err)` or `errors.Is(err, fs.ErrNotExist)`
|
||||||
|
- [ ] Fix `AddFile` to verify size — check `totalRead == size` after reading, return error on mismatch
|
||||||
|
- [ ] Specify path invariants — add proto comments (UTF-8, forward-slash, relative, no `..`, no leading `/`); validate in `Builder.AddFile` and `Builder.AddFileWithHash`
|
||||||
|
|
||||||
|
### Phase 2: CLI polish
|
||||||
|
|
||||||
|
- [ ] Fix flag naming — all CLI flags use kebab-case as primary (`--include-dotfiles`, `--follow-symlinks`)
|
||||||
|
- [ ] Fix URL construction in fetch — use `BaseURL.JoinPath()` or `url.JoinPath()` instead of string concatenation
|
||||||
|
- [ ] Add progress rate-limiting to Checker — throttle to once per second, matching Scanner
|
||||||
|
- [ ] Add `--deterministic` flag (or make it default) — omit `createdAt`, sort files
|
||||||
|
|
||||||
|
### Phase 3: Robustness
|
||||||
|
|
||||||
|
- [ ] Replace GPG subprocess with pure-Go crypto — `github.com/ProtonMail/go-crypto` or Ed25519/signify
|
||||||
|
- [ ] Add timeout to any remaining subprocess calls
|
||||||
|
- [ ] Add fuzzing tests for `NewManifestFromReader`
|
||||||
|
- [ ] Add retry logic to fetch — exponential backoff for transient HTTP errors
|
||||||
|
|
||||||
|
### Phase 4: Format finalization
|
||||||
|
|
||||||
|
- [ ] Remove or deprecate `atime` from proto (pending design question answer)
|
||||||
|
- [ ] Reserve `optional uint32 mode = 305` in `MFFilePath` for future file permissions
|
||||||
|
- [ ] Add version byte after magic — `ZNAVSRFG\x01` for format version 1
|
||||||
|
- [ ] Write format specification document — separate from README: magic, outer structure, compression, inner structure, path invariants, signature scheme, canonical serialization
|
||||||
|
|
||||||
|
### Phase 5: Release prep
|
||||||
|
|
||||||
|
- [ ] Finalize Go module path
|
||||||
|
- [ ] Audit all error messages for consistency and helpfulness
|
||||||
|
- [ ] Add `--version` output matching SemVer
|
||||||
|
- [ ] Tag v1.0.0
|
||||||
12
bin/gitrev.sh
Normal file
12
bin/gitrev.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
if [[ ! -z "$DRONE_COMMIT_SHA" ]]; then
|
||||||
|
echo "${DRONE_COMMIT_SHA:0:7}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -z "$GITREV" ]]; then
|
||||||
|
echo $GITREV
|
||||||
|
else
|
||||||
|
git describe --always --dirty=-dirty
|
||||||
|
fi
|
||||||
@@ -3,15 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
mfer "git.eeqj.de/sneak/mfer/src"
|
"sneak.berlin/go/mfer/internal/cli"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Appname string = "mfer"
|
Appname string = "mfer"
|
||||||
Version string
|
Version string
|
||||||
Buildarch string
|
Gitrev string
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
os.Exit(mfer.Run(Appname, Version, Buildarch))
|
os.Exit(cli.Run(Appname, Version, Gitrev))
|
||||||
}
|
}
|
||||||
|
|||||||
11
cmd/mfer/main_test.go
Normal file
11
cmd/mfer/main_test.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild(t *testing.T) {
|
||||||
|
assert.True(t, true)
|
||||||
|
}
|
||||||
19
contrib/usage.sh
Executable file
19
contrib/usage.sh
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# usage.sh - Generate and check a manifest from the repo
|
||||||
|
# Run from repo root: ./contrib/usage.sh
|
||||||
|
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
MANIFEST="$TMPDIR/index.mf"
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "Building mfer..."
|
||||||
|
go build -o "$TMPDIR/mfer" ./cmd/mfer
|
||||||
|
|
||||||
|
"$TMPDIR/mfer" generate -o "$MANIFEST" .
|
||||||
|
"$TMPDIR/mfer" check --base . "$MANIFEST"
|
||||||
43
go.mod
43
go.mod
@@ -1,11 +1,44 @@
|
|||||||
module git.eeqj.de/sneak/mfer
|
module sneak.berlin/go/mfer
|
||||||
|
|
||||||
go 1.16
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/apex/log v1.9.0
|
||||||
|
github.com/davecgh/go-spew v1.1.1
|
||||||
|
github.com/dustin/go-humanize v1.0.1
|
||||||
|
github.com/google/uuid v1.1.2
|
||||||
|
github.com/klauspost/compress v1.18.2
|
||||||
|
github.com/multiformats/go-multihash v0.2.3
|
||||||
github.com/pterm/pterm v0.12.35
|
github.com/pterm/pterm v0.12.35
|
||||||
github.com/spf13/afero v1.8.0
|
github.com/spf13/afero v1.8.0
|
||||||
github.com/urfave/cli/v2 v2.3.0
|
github.com/stretchr/testify v1.8.1
|
||||||
google.golang.org/protobuf v1.27.1
|
github.com/urfave/cli/v2 v2.23.6
|
||||||
|
google.golang.org/protobuf v1.28.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/atomicgo/cursor v0.0.1 // indirect
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||||
|
github.com/fatih/color v1.7.0 // indirect
|
||||||
|
github.com/gookit/color v1.4.2 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.9 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.2 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.8 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.13 // indirect
|
||||||
|
github.com/minio/sha256-simd v1.0.0 // indirect
|
||||||
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
|
github.com/multiformats/go-varint v0.0.6 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.2.0 // indirect
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||||
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect
|
||||||
|
golang.org/x/sys v0.1.0 // indirect
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
|
||||||
|
golang.org/x/text v0.3.6 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
lukechampine.com/blake3 v1.1.6 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
115
go.sum
115
go.sum
@@ -44,8 +44,15 @@ github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBE
|
|||||||
github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k=
|
github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k=
|
||||||
github.com/MarvinJWendt/testza v0.2.12 h1:/PRp/BF+27t2ZxynTiqj0nyND5PbOtfJS0SuTuxmgeg=
|
github.com/MarvinJWendt/testza v0.2.12 h1:/PRp/BF+27t2ZxynTiqj0nyND5PbOtfJS0SuTuxmgeg=
|
||||||
github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI=
|
github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI=
|
||||||
|
github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0=
|
||||||
|
github.com/apex/log v1.9.0/go.mod h1:m82fZlWIuiWzWP04XCTXmnX0xRkYYbCdYn8jbJeLBEA=
|
||||||
|
github.com/apex/logs v1.0.0/go.mod h1:XzxuLZ5myVHDy9SAmYpamKKRNApGj54PfYLcFrXqDwo=
|
||||||
|
github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy8kCu4PNA+aP7WUV72eXWJeP9/r3/K9aLE=
|
||||||
|
github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys=
|
||||||
github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU=
|
github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU=
|
||||||
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
|
||||||
|
github.com/aws/aws-sdk-go v1.20.6/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
|
||||||
|
github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I=
|
||||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
@@ -54,20 +61,26 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
|
|||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
|
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||||
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
@@ -94,8 +107,6 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
|
|||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
@@ -123,6 +134,8 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
|
|||||||
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
@@ -131,19 +144,48 @@ github.com/gookit/color v1.4.2 h1:tXy44JFSFkKnELV6WaMo/lLfu/meqITX3iAV52do7lk=
|
|||||||
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
|
github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||||
|
github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
|
||||||
|
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||||
|
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
|
||||||
|
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
|
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
|
||||||
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
|
||||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
|
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||||
|
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||||
|
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||||
|
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
|
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
|
||||||
|
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
|
||||||
|
github.com/multiformats/go-varint v0.0.6 h1:gk85QWKxh3TazbLxED/NlDVv8+q+ReFJk7Y2W/KhfNY=
|
||||||
|
github.com/multiformats/go-varint v0.0.6/go.mod h1:3Ls8CIEsrijN6+B7PbrXRPxHRPuXSrVKRY101jdMZYE=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -158,23 +200,43 @@ github.com/pterm/pterm v0.12.35 h1:A/vHwDM+WByn0sTPlpL2L6kOTy12xqZuwNFMF/NlA+U=
|
|||||||
github.com/pterm/pterm v0.12.35/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
|
github.com/pterm/pterm v0.12.35/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8=
|
||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
github.com/smartystreets/assertions v1.0.0/go.mod h1:kHHU4qYBaI3q23Pp3VPrmWhuIUrLW/7eUrw0BU5VaoM=
|
||||||
|
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9/go.mod h1:SnhjPscd9TpLiy1LpzGSKh3bXCfxxXuqd9xmQJy3slM=
|
||||||
|
github.com/smartystreets/gunit v1.0.0/go.mod h1:qwPWnhz6pn0NnRBP++URONOVyNkPyr4SauJk4cUOwJs=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spf13/afero v1.8.0 h1:5MmtuhAgYeU6qpa7w7bP0dv6MBYuup0vekhSpSkoq60=
|
github.com/spf13/afero v1.8.0 h1:5MmtuhAgYeU6qpa7w7bP0dv6MBYuup0vekhSpSkoq60=
|
||||||
github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/tj/assert v0.0.0-20171129193455-018094318fb0/go.mod h1:mZ9/Rh9oLWpLLDRpvE+3b7gP/C2YyLFYxNmcLnPTMe0=
|
||||||
|
github.com/tj/assert v0.0.3 h1:Df/BlaZ20mq6kuai7f5z2TvPFiwC3xaWJSDQNiIS3Rk=
|
||||||
|
github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk=
|
||||||
|
github.com/tj/go-buffer v1.1.0/go.mod h1:iyiJpfFcR2B9sXu7KvjbT9fpM4mOelRSDTbntVj52Uc=
|
||||||
|
github.com/tj/go-elastic v0.0.0-20171221160941-36157cbbebc2/go.mod h1:WjeM0Oo1eNAjXGDx2yma7uG2XoyRZTq1uv3M/o7imD0=
|
||||||
|
github.com/tj/go-kinesis v0.0.0-20171128231115-08b17f58cb1b/go.mod h1:/yhzCV0xPfx6jb1bBgRFjl5lytqVqZXEaeqWP8lTEao=
|
||||||
|
github.com/tj/go-spin v1.1.0/go.mod h1:Mg1mzmePZm4dva8Qz60H2lHwmJ2loum4VIrLgVnKwh4=
|
||||||
|
github.com/urfave/cli/v2 v2.23.6 h1:iWmtKD+prGo1nKUtLO0Wg4z9esfBM4rAV4QRLQiEmJ4=
|
||||||
|
github.com/urfave/cli/v2 v2.23.6/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc=
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||||
|
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -186,12 +248,15 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|||||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
||||||
|
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -227,6 +292,7 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
@@ -276,7 +342,9 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -310,8 +378,9 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c h1:taxlMj0D/1sOAuv/CbSD+MMDof2vbyPTqz5FNYKpXt8=
|
|
||||||
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -322,8 +391,9 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
|
|
||||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
@@ -376,8 +446,8 @@ golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4f
|
|||||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
@@ -468,17 +538,22 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
|
|||||||
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||||
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
|
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
@@ -486,6 +561,8 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
|||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
lukechampine.com/blake3 v1.1.6 h1:H3cROdztr7RCfoaTpGZFQsrqvweFLrqS73j7L7cmR5c=
|
||||||
|
lukechampine.com/blake3 v1.1.6/go.mod h1:tkKEOtDkNtklkXtLNEOGNq5tcV90tJiA1vAA12R78LA=
|
||||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
|
|||||||
15
internal/bork/error.go
Normal file
15
internal/bork/error.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package bork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMissingMagic = errors.New("missing magic bytes in file")
|
||||||
|
ErrFileTruncated = errors.New("file/stream is truncated abnormally")
|
||||||
|
)
|
||||||
|
|
||||||
|
func Newf(format string, args ...interface{}) error {
|
||||||
|
return fmt.Errorf(format, args...)
|
||||||
|
}
|
||||||
11
internal/bork/error_test.go
Normal file
11
internal/bork/error_test.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package bork
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild(t *testing.T) {
|
||||||
|
assert.NotNil(t, ErrMissingMagic)
|
||||||
|
}
|
||||||
192
internal/cli/check.go
Normal file
192
internal/cli/check.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// findManifest looks for a manifest file in the given directory.
|
||||||
|
// It checks for index.mf and .index.mf, returning the first one found.
|
||||||
|
func findManifest(fs afero.Fs, dir string) (string, error) {
|
||||||
|
candidates := []string{"index.mf", ".index.mf"}
|
||||||
|
for _, name := range candidates {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
exists, err := afero.Exists(fs, path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no manifest found in %s (looked for index.mf and .index.mf)", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfa *CLIApp) checkManifestOperation(ctx *cli.Context) error {
|
||||||
|
log.Debug("checkManifestOperation()")
|
||||||
|
|
||||||
|
manifestPath, err := mfa.resolveManifestArg(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL manifests need to be downloaded to a temp file for the checker
|
||||||
|
if isHTTPURL(manifestPath) {
|
||||||
|
rc, fetchErr := mfa.openManifestReader(manifestPath)
|
||||||
|
if fetchErr != nil {
|
||||||
|
return fmt.Errorf("check: %w", fetchErr)
|
||||||
|
}
|
||||||
|
tmpFile, tmpErr := afero.TempFile(mfa.Fs, "", "mfer-manifest-*.mf")
|
||||||
|
if tmpErr != nil {
|
||||||
|
_ = rc.Close()
|
||||||
|
return fmt.Errorf("check: failed to create temp file: %w", tmpErr)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
_, cpErr := io.Copy(tmpFile, rc)
|
||||||
|
_ = rc.Close()
|
||||||
|
_ = tmpFile.Close()
|
||||||
|
if cpErr != nil {
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("check: failed to download manifest: %w", cpErr)
|
||||||
|
}
|
||||||
|
defer func() { _ = mfa.Fs.Remove(tmpPath) }()
|
||||||
|
manifestPath = tmpPath
|
||||||
|
}
|
||||||
|
|
||||||
|
basePath := ctx.String("base")
|
||||||
|
showProgress := ctx.Bool("progress")
|
||||||
|
|
||||||
|
log.Infof("checking manifest %s with base %s", manifestPath, basePath)
|
||||||
|
|
||||||
|
// Create checker
|
||||||
|
chk, err := mfer.NewChecker(manifestPath, basePath, mfa.Fs)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check signature requirement
|
||||||
|
requiredSigner := ctx.String("require-signature")
|
||||||
|
if requiredSigner != "" {
|
||||||
|
// Validate fingerprint format: must be exactly 40 hex characters
|
||||||
|
if len(requiredSigner) != 40 {
|
||||||
|
return fmt.Errorf("invalid fingerprint: must be exactly 40 hex characters, got %d", len(requiredSigner))
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(requiredSigner); err != nil {
|
||||||
|
return fmt.Errorf("invalid fingerprint: must be valid hex: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !chk.IsSigned() {
|
||||||
|
return fmt.Errorf("manifest is not signed, but signature from %s is required", requiredSigner)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract fingerprint from the embedded public key (not from the signer field)
|
||||||
|
// This validates the key is importable and gets its actual fingerprint
|
||||||
|
embeddedFP, err := chk.ExtractEmbeddedSigningKeyFP()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to extract fingerprint from embedded signing key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare fingerprints - must be exact match (case-insensitive)
|
||||||
|
if !strings.EqualFold(embeddedFP, requiredSigner) {
|
||||||
|
return fmt.Errorf("embedded signing key fingerprint %s does not match required %s", embeddedFP, requiredSigner)
|
||||||
|
}
|
||||||
|
log.Infof("manifest signature verified (signer: %s)", embeddedFP)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("manifest contains %d files, %s", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())))
|
||||||
|
|
||||||
|
// Set up results channel
|
||||||
|
results := make(chan mfer.Result, 1)
|
||||||
|
|
||||||
|
// Set up progress channel
|
||||||
|
var progress chan mfer.CheckStatus
|
||||||
|
if showProgress {
|
||||||
|
progress = make(chan mfer.CheckStatus, 1)
|
||||||
|
go func() {
|
||||||
|
for status := range progress {
|
||||||
|
if status.ETA > 0 {
|
||||||
|
log.Progressf("Checking: %d/%d files, %s/s, ETA %s, %d failures",
|
||||||
|
status.CheckedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||||
|
status.ETA.Round(time.Second),
|
||||||
|
status.Failures)
|
||||||
|
} else {
|
||||||
|
log.Progressf("Checking: %d/%d files, %s/s, %d failures",
|
||||||
|
status.CheckedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||||
|
status.Failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.ProgressDone()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process results in a goroutine
|
||||||
|
var failures int64
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for result := range results {
|
||||||
|
if result.Status != mfer.StatusOK {
|
||||||
|
failures++
|
||||||
|
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||||
|
} else {
|
||||||
|
log.Verbosef("%s: %s", result.Status, result.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Run check
|
||||||
|
err = chk.Check(ctx.Context, results, progress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for results processing to complete
|
||||||
|
<-done
|
||||||
|
|
||||||
|
// Check for extra files if requested
|
||||||
|
if ctx.Bool("no-extra-files") {
|
||||||
|
extraResults := make(chan mfer.Result, 1)
|
||||||
|
extraDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for result := range extraResults {
|
||||||
|
failures++
|
||||||
|
log.Infof("%s: %s (%s)", result.Status, result.Path, result.Message)
|
||||||
|
}
|
||||||
|
close(extraDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
err = chk.FindExtraFiles(ctx.Context, extraResults)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for extra files: %w", err)
|
||||||
|
}
|
||||||
|
<-extraDone
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(mfa.startupTime).Seconds()
|
||||||
|
rate := float64(chk.TotalBytes()) / elapsed
|
||||||
|
if failures == 0 {
|
||||||
|
log.Infof("checked %d files (%s) in %.1fs (%s/s): all OK", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)))
|
||||||
|
} else {
|
||||||
|
log.Infof("checked %d files (%s) in %.1fs (%s/s): %d failed", chk.FileCount(), humanize.IBytes(uint64(chk.TotalBytes())), elapsed, humanize.IBytes(uint64(rate)), failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
if failures > 0 {
|
||||||
|
mfa.exitCode = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
68
internal/cli/entry.go
Normal file
68
internal/cli/entry.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NO_COLOR disables colored output when set. Automatically true if the
|
||||||
|
// NO_COLOR environment variable is present (per https://no-color.org/).
|
||||||
|
var NO_COLOR bool
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
NO_COLOR = false
|
||||||
|
if _, exists := os.LookupEnv("NO_COLOR"); exists {
|
||||||
|
NO_COLOR = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunOptions contains all configuration for running the CLI application.
|
||||||
|
// Use DefaultRunOptions for standard CLI execution, or construct manually for testing.
|
||||||
|
type RunOptions struct {
|
||||||
|
Appname string // Application name displayed in help and version output
|
||||||
|
Version string // Version string (typically set at build time)
|
||||||
|
Gitrev string // Git revision hash (typically set at build time)
|
||||||
|
Args []string // Command-line arguments (typically os.Args)
|
||||||
|
Stdin io.Reader // Standard input stream
|
||||||
|
Stdout io.Writer // Standard output stream
|
||||||
|
Stderr io.Writer // Standard error stream
|
||||||
|
Fs afero.Fs // Filesystem abstraction for file operations
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultRunOptions returns RunOptions configured for normal CLI execution.
|
||||||
|
func DefaultRunOptions(appname, version, gitrev string) *RunOptions {
|
||||||
|
return &RunOptions{
|
||||||
|
Appname: appname,
|
||||||
|
Version: version,
|
||||||
|
Gitrev: gitrev,
|
||||||
|
Args: os.Args,
|
||||||
|
Stdin: os.Stdin,
|
||||||
|
Stdout: os.Stdout,
|
||||||
|
Stderr: os.Stderr,
|
||||||
|
Fs: afero.NewOsFs(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run creates and runs the CLI application with default options.
|
||||||
|
func Run(appname, version, gitrev string) int {
|
||||||
|
return RunWithOptions(DefaultRunOptions(appname, version, gitrev))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithOptions creates and runs the CLI application with the given options.
|
||||||
|
func RunWithOptions(opts *RunOptions) int {
|
||||||
|
m := &CLIApp{
|
||||||
|
appname: opts.Appname,
|
||||||
|
version: opts.Version,
|
||||||
|
gitrev: opts.Gitrev,
|
||||||
|
exitCode: 0,
|
||||||
|
Stdin: opts.Stdin,
|
||||||
|
Stdout: opts.Stdout,
|
||||||
|
Stderr: opts.Stderr,
|
||||||
|
Fs: opts.Fs,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.run(opts.Args)
|
||||||
|
return m.exitCode
|
||||||
|
}
|
||||||
593
internal/cli/entry_test.go
Normal file
593
internal/cli/entry_test.go
Normal file
@@ -0,0 +1,593 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
urfcli "github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Prevent urfave/cli from calling os.Exit during tests
|
||||||
|
urfcli.OsExiter = func(code int) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuild(t *testing.T) {
|
||||||
|
m := &CLIApp{}
|
||||||
|
assert.NotNil(t, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpts(args []string, fs afero.Fs) *RunOptions {
|
||||||
|
return &RunOptions{
|
||||||
|
Appname: "mfer",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Gitrev: "abc123",
|
||||||
|
Args: args,
|
||||||
|
Stdin: &bytes.Buffer{},
|
||||||
|
Stdout: &bytes.Buffer{},
|
||||||
|
Stderr: &bytes.Buffer{},
|
||||||
|
Fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionCommand(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
opts := testOpts([]string{"mfer", "version"}, fs)
|
||||||
|
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
stdout := opts.Stdout.(*bytes.Buffer).String()
|
||||||
|
assert.Contains(t, stdout, mfer.Version)
|
||||||
|
assert.Contains(t, stdout, "abc123")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHelpCommand(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
opts := testOpts([]string{"mfer", "--help"}, fs)
|
||||||
|
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
stdout := opts.Stdout.(*bytes.Buffer).String()
|
||||||
|
assert.Contains(t, stdout, "generate")
|
||||||
|
assert.Contains(t, stdout, "check")
|
||||||
|
assert.Contains(t, stdout, "fetch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateCommand(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files in memory filesystem
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("test content"), 0o644))
|
||||||
|
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, exitCode, "stderr: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||||
|
|
||||||
|
// Verify manifest was created
|
||||||
|
exists, err := afero.Exists(fs, "/testdir/test.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAndCheckCommand(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files with subdirectory
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file2.txt", []byte("test content"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||||
|
|
||||||
|
// Check manifest
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 0, exitCode, "check failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCommandWithMissingFile(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||||
|
|
||||||
|
// Delete the file
|
||||||
|
require.NoError(t, fs.Remove("/testdir/file1.txt"))
|
||||||
|
|
||||||
|
// Check manifest - should fail
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "check should have failed for missing file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCommandWithCorruptedFile(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||||
|
|
||||||
|
// Corrupt the file (change content but keep same size)
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("HELLO WORLD"), 0o644))
|
||||||
|
|
||||||
|
// Check manifest - should fail with hash mismatch
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "check should have failed for corrupted file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckCommandWithSizeMismatch(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello world"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode, "generate failed: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||||
|
|
||||||
|
// Change file size
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("different size content here"), 0o644))
|
||||||
|
|
||||||
|
// Check manifest - should fail with size mismatch
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/testdir/test.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "check should have failed for size mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBannerOutput(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
// Run without -q to see banner
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Banner ASCII art should be in stdout
|
||||||
|
stdout := opts.Stdout.(*bytes.Buffer).String()
|
||||||
|
assert.Contains(t, stdout, "___")
|
||||||
|
assert.Contains(t, stdout, "\\")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnknownCommand(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
opts := testOpts([]string{"mfer", "unknown"}, fs)
|
||||||
|
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateExcludesDotfilesByDefault(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files including dotfiles
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden", []byte("secret"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest without --include-dotfiles (default excludes dotfiles)
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Check that manifest exists
|
||||||
|
exists, _ := afero.Exists(fs, "/testdir/test.mf")
|
||||||
|
assert.True(t, exists)
|
||||||
|
|
||||||
|
// Verify manifest only has 1 file (the non-dotfile)
|
||||||
|
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/test.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 1)
|
||||||
|
assert.Equal(t, "file1.txt", manifest.Files()[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWithIncludeDotfiles(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files including dotfiles
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden", []byte("secret"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest with --include-dotfiles
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "--include-dotfiles", "-o", "/testdir/test.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Verify manifest has 2 files (including dotfile)
|
||||||
|
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/test.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultipleInputPaths(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files in multiple directories
|
||||||
|
require.NoError(t, fs.MkdirAll("/dir1", 0o755))
|
||||||
|
require.NoError(t, fs.MkdirAll("/dir2", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/dir1/file1.txt", []byte("content1"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/dir2/file2.txt", []byte("content2"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest from multiple paths
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/dir1", "/dir2"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 0, exitCode, "stderr: %s", opts.Stderr.(*bytes.Buffer).String())
|
||||||
|
|
||||||
|
exists, _ := afero.Exists(fs, "/output.mf")
|
||||||
|
assert.True(t, exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoExtraFilesPass(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("world"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Check with --no-extra-files (should pass - no extra files)
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoExtraFilesFail(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Add an extra file after manifest generation
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/extra.txt", []byte("extra"), 0o644))
|
||||||
|
|
||||||
|
// Check with --no-extra-files (should fail - extra file exists)
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "check should fail when extra files exist")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoExtraFilesWithSubdirectory(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files with subdirectory
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file2.txt", []byte("world"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Add extra file in subdirectory
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/extra.txt", []byte("extra"), 0o644))
|
||||||
|
|
||||||
|
// Check with --no-extra-files (should fail)
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--no-extra-files", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "check should fail when extra files exist in subdirectory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckWithoutNoExtraFilesIgnoresExtra(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Add extra file
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/extra.txt", []byte("extra"), 0o644))
|
||||||
|
|
||||||
|
// Check WITHOUT --no-extra-files (should pass - extra files ignored)
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 0, exitCode, "check without --no-extra-files should ignore extra files")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAtomicWriteNoTempFileOnSuccess(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Verify output file exists
|
||||||
|
exists, err := afero.Exists(fs, "/output.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "output file should exist")
|
||||||
|
|
||||||
|
// Verify temp file does NOT exist
|
||||||
|
tmpExists, err := afero.Exists(fs, "/output.mf.tmp")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, tmpExists, "temp file should not exist after successful generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAtomicWriteOverwriteWithForce(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
// Create existing manifest with different content
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/output.mf", []byte("old content"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest with --force
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-f", "-o", "/output.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Verify output file exists and was overwritten
|
||||||
|
content, err := afero.ReadFile(fs, "/output.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, "old content", string(content), "manifest should be overwritten")
|
||||||
|
|
||||||
|
// Verify temp file does NOT exist
|
||||||
|
tmpExists, err := afero.Exists(fs, "/output.mf.tmp")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, tmpExists, "temp file should not exist after successful generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateFailsWithoutForceWhenOutputExists(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
// Create existing manifest
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/output.mf", []byte("existing"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest WITHOUT --force (should fail)
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "should fail when output exists without --force")
|
||||||
|
|
||||||
|
// Verify original content is preserved
|
||||||
|
content, err := afero.ReadFile(fs, "/output.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "existing", string(content), "original file should be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAtomicWriteUsesTemp(t *testing.T) {
|
||||||
|
// This test verifies that generate uses a temp file by checking
|
||||||
|
// that the output file doesn't exist until generation completes.
|
||||||
|
// We do this by generating to a path and verifying the temp file
|
||||||
|
// pattern is used (output.mf.tmp -> output.mf)
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test file
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode)
|
||||||
|
|
||||||
|
// Both output file should exist and temp should not
|
||||||
|
exists, _ := afero.Exists(fs, "/output.mf")
|
||||||
|
assert.True(t, exists, "output file should exist")
|
||||||
|
|
||||||
|
tmpExists, _ := afero.Exists(fs, "/output.mf.tmp")
|
||||||
|
assert.False(t, tmpExists, "temp file should be cleaned up")
|
||||||
|
|
||||||
|
// Verify manifest is valid (not empty)
|
||||||
|
content, err := afero.ReadFile(fs, "/output.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, len(content) > 0, "manifest should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// failingWriterFs wraps a filesystem and makes writes fail after N bytes
|
||||||
|
type failingWriterFs struct {
|
||||||
|
afero.Fs
|
||||||
|
failAfter int64
|
||||||
|
written int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingFile struct {
|
||||||
|
afero.File
|
||||||
|
fs *failingWriterFs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingFile) Write(p []byte) (int, error) {
|
||||||
|
f.fs.written += int64(len(p))
|
||||||
|
if f.fs.written > f.fs.failAfter {
|
||||||
|
return 0, fmt.Errorf("simulated write failure")
|
||||||
|
}
|
||||||
|
return f.File.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs *failingWriterFs) Create(name string) (afero.File, error) {
|
||||||
|
f, err := fs.Fs.Create(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &failingFile{File: f, fs: fs}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAtomicWriteCleansUpOnError(t *testing.T) {
|
||||||
|
baseFs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create test files - need enough content to trigger the write failure
|
||||||
|
require.NoError(t, baseFs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(baseFs, "/testdir/file1.txt", []byte("hello world this is a test file"), 0o644))
|
||||||
|
|
||||||
|
// Wrap with failing writer that fails after writing some bytes
|
||||||
|
fs := &failingWriterFs{Fs: baseFs, failAfter: 10}
|
||||||
|
|
||||||
|
// Generate manifest - should fail during write
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "should fail due to write error")
|
||||||
|
|
||||||
|
// With atomic writes: output.mf should NOT exist (temp was cleaned up)
|
||||||
|
// With non-atomic writes: output.mf WOULD exist (partial/empty)
|
||||||
|
exists, _ := afero.Exists(baseFs, "/output.mf")
|
||||||
|
assert.False(t, exists, "output file should not exist after failed generation (atomic write)")
|
||||||
|
|
||||||
|
// Temp file should also not exist
|
||||||
|
tmpExists, _ := afero.Exists(baseFs, "/output.mf.tmp")
|
||||||
|
assert.False(t, tmpExists, "temp file should be cleaned up after failed generation")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateValidatesInputPaths(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Create one valid directory
|
||||||
|
require.NoError(t, fs.MkdirAll("/validdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/validdir/file.txt", []byte("content"), 0o644))
|
||||||
|
|
||||||
|
t.Run("nonexistent path fails fast", func(t *testing.T) {
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/nonexistent"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode)
|
||||||
|
stderr := opts.Stderr.(*bytes.Buffer).String()
|
||||||
|
assert.Contains(t, stderr, "path does not exist")
|
||||||
|
assert.Contains(t, stderr, "/nonexistent")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("mix of valid and invalid paths fails fast", func(t *testing.T) {
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir", "/alsononexistent"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode)
|
||||||
|
stderr := opts.Stderr.(*bytes.Buffer).String()
|
||||||
|
assert.Contains(t, stderr, "path does not exist")
|
||||||
|
assert.Contains(t, stderr, "/alsononexistent")
|
||||||
|
|
||||||
|
// Output file should not have been created
|
||||||
|
exists, _ := afero.Exists(fs, "/output.mf")
|
||||||
|
assert.False(t, exists, "output file should not exist when path validation fails")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid paths succeed", func(t *testing.T) {
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/output.mf", "/validdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckDetectsManifestCorruption(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
|
||||||
|
// Create many small files with random names to generate a ~1MB manifest
|
||||||
|
// Each manifest entry is roughly 50-60 bytes, so we need ~20000 files
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
|
||||||
|
numFiles := 20000
|
||||||
|
for i := 0; i < numFiles; i++ {
|
||||||
|
// Generate random filename
|
||||||
|
filename := fmt.Sprintf("/testdir/%08x%08x%08x.dat", rng.Uint32(), rng.Uint32(), rng.Uint32())
|
||||||
|
// Small random content
|
||||||
|
content := make([]byte, 16+rng.Intn(48))
|
||||||
|
rng.Read(content)
|
||||||
|
require.NoError(t, afero.WriteFile(fs, filename, content, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate manifest outside of testdir
|
||||||
|
opts := testOpts([]string{"mfer", "generate", "-q", "-o", "/manifest.mf", "/testdir"}, fs)
|
||||||
|
exitCode := RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode, "generate should succeed")
|
||||||
|
|
||||||
|
// Read the valid manifest and verify it's approximately 1MB
|
||||||
|
validManifest, err := afero.ReadFile(fs, "/manifest.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, len(validManifest) >= 1024*1024, "manifest should be at least 1MB, got %d bytes", len(validManifest))
|
||||||
|
t.Logf("manifest size: %d bytes (%d files)", len(validManifest), numFiles)
|
||||||
|
|
||||||
|
// First corruption: truncate the manifest
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", validManifest[:len(validManifest)/2], 0o644))
|
||||||
|
|
||||||
|
// Check should fail with truncated manifest
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "check should fail with truncated manifest")
|
||||||
|
|
||||||
|
// Verify check passes with valid manifest
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", validManifest, 0o644))
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
require.Equal(t, 0, exitCode, "check should pass with valid manifest")
|
||||||
|
|
||||||
|
// Now do 500 random corruption iterations
|
||||||
|
for i := 0; i < 500; i++ {
|
||||||
|
// Corrupt: write a random byte at a random offset
|
||||||
|
corrupted := make([]byte, len(validManifest))
|
||||||
|
copy(corrupted, validManifest)
|
||||||
|
|
||||||
|
offset := rng.Intn(len(corrupted))
|
||||||
|
originalByte := corrupted[offset]
|
||||||
|
// Make sure we actually change the byte
|
||||||
|
newByte := byte(rng.Intn(256))
|
||||||
|
for newByte == originalByte {
|
||||||
|
newByte = byte(rng.Intn(256))
|
||||||
|
}
|
||||||
|
corrupted[offset] = newByte
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", corrupted, 0o644))
|
||||||
|
|
||||||
|
// Check should fail with corrupted manifest
|
||||||
|
opts = testOpts([]string{"mfer", "check", "-q", "--base", "/testdir", "/manifest.mf"}, fs)
|
||||||
|
exitCode = RunWithOptions(opts)
|
||||||
|
assert.Equal(t, 1, exitCode, "iteration %d: check should fail with corrupted manifest (offset %d, 0x%02x -> 0x%02x)",
|
||||||
|
i, offset, originalByte, newByte)
|
||||||
|
|
||||||
|
// Restore valid manifest for next iteration
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/manifest.mf", validManifest, 0o644))
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/cli/export.go
Normal file
72
internal/cli/export.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportEntry represents a single file entry in the exported JSON output.
|
||||||
|
type ExportEntry struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Hashes []string `json:"hashes"`
|
||||||
|
Mtime *string `json:"mtime,omitempty"`
|
||||||
|
Ctime *string `json:"ctime,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfa *CLIApp) exportManifestOperation(ctx *cli.Context) error {
|
||||||
|
pathOrURL, err := mfa.resolveManifestArg(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := mfa.openManifestReader(pathOrURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("export: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rc.Close() }()
|
||||||
|
|
||||||
|
manifest, err := mfer.NewManifestFromReader(rc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("export: failed to parse manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := manifest.Files()
|
||||||
|
entries := make([]ExportEntry, 0, len(files))
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
entry := ExportEntry{
|
||||||
|
Path: f.Path,
|
||||||
|
Size: f.Size,
|
||||||
|
Hashes: make([]string, 0, len(f.Hashes)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range f.Hashes {
|
||||||
|
entry.Hashes = append(entry.Hashes, hex.EncodeToString(h.MultiHash))
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.Mtime != nil {
|
||||||
|
t := time.Unix(f.Mtime.Seconds, int64(f.Mtime.Nanos)).UTC().Format(time.RFC3339Nano)
|
||||||
|
entry.Mtime = &t
|
||||||
|
}
|
||||||
|
if f.Ctime != nil {
|
||||||
|
t := time.Unix(f.Ctime.Seconds, int64(f.Ctime.Nanos)).UTC().Format(time.RFC3339Nano)
|
||||||
|
entry.Ctime = &t
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
enc := json.NewEncoder(mfa.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
if err := enc.Encode(entries); err != nil {
|
||||||
|
return fmt.Errorf("export: failed to encode JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
137
internal/cli/export_test.go
Normal file
137
internal/cli/export_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// buildTestManifest creates a manifest from in-memory files and returns its bytes.
|
||||||
|
func buildTestManifest(t *testing.T, files map[string][]byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
sourceFs := afero.NewMemMapFs()
|
||||||
|
for path, content := range files {
|
||||||
|
require.NoError(t, sourceFs.MkdirAll("/", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(sourceFs, "/"+path, content, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := &mfer.ScannerOptions{Fs: sourceFs}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportManifestOperation(t *testing.T) {
|
||||||
|
testFiles := map[string][]byte{
|
||||||
|
"hello.txt": []byte("Hello, World!"),
|
||||||
|
"sub/file.txt": []byte("nested content"),
|
||||||
|
}
|
||||||
|
manifestData := buildTestManifest(t, testFiles)
|
||||||
|
|
||||||
|
// Write manifest to memfs
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/test.mf", manifestData, 0o644))
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
exitCode := RunWithOptions(&RunOptions{
|
||||||
|
Appname: "mfer",
|
||||||
|
Args: []string{"mfer", "export", "/test.mf"},
|
||||||
|
Stdin: &bytes.Buffer{},
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
Fs: fs,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Equal(t, 0, exitCode, "stderr: %s", stderr.String())
|
||||||
|
|
||||||
|
var entries []ExportEntry
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &entries))
|
||||||
|
assert.Len(t, entries, 2)
|
||||||
|
|
||||||
|
// Verify entries have expected fields
|
||||||
|
pathSet := make(map[string]bool)
|
||||||
|
for _, e := range entries {
|
||||||
|
pathSet[e.Path] = true
|
||||||
|
assert.NotEmpty(t, e.Hashes, "entry %s should have hashes", e.Path)
|
||||||
|
assert.Greater(t, e.Size, int64(0), "entry %s should have positive size", e.Path)
|
||||||
|
}
|
||||||
|
assert.True(t, pathSet["hello.txt"])
|
||||||
|
assert.True(t, pathSet["sub/file.txt"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportFromHTTPURL(t *testing.T) {
|
||||||
|
testFiles := map[string][]byte{
|
||||||
|
"a.txt": []byte("aaa"),
|
||||||
|
}
|
||||||
|
manifestData := buildTestManifest(t, testFiles)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
_, _ = w.Write(manifestData)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
exitCode := RunWithOptions(&RunOptions{
|
||||||
|
Appname: "mfer",
|
||||||
|
Args: []string{"mfer", "export", server.URL + "/index.mf"},
|
||||||
|
Stdin: &bytes.Buffer{},
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
Fs: afero.NewMemMapFs(),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Equal(t, 0, exitCode, "stderr: %s", stderr.String())
|
||||||
|
|
||||||
|
var entries []ExportEntry
|
||||||
|
require.NoError(t, json.Unmarshal(stdout.Bytes(), &entries))
|
||||||
|
assert.Len(t, entries, 1)
|
||||||
|
assert.Equal(t, "a.txt", entries[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFromHTTPURL(t *testing.T) {
|
||||||
|
testFiles := map[string][]byte{
|
||||||
|
"one.txt": []byte("1"),
|
||||||
|
"two.txt": []byte("22"),
|
||||||
|
}
|
||||||
|
manifestData := buildTestManifest(t, testFiles)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write(manifestData)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
exitCode := RunWithOptions(&RunOptions{
|
||||||
|
Appname: "mfer",
|
||||||
|
Args: []string{"mfer", "list", server.URL + "/index.mf"},
|
||||||
|
Stdin: &bytes.Buffer{},
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
Fs: afero.NewMemMapFs(),
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Equal(t, 0, exitCode, "stderr: %s", stderr.String())
|
||||||
|
output := stdout.String()
|
||||||
|
assert.Contains(t, output, "one.txt")
|
||||||
|
assert.Contains(t, output, "two.txt")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHTTPURL(t *testing.T) {
|
||||||
|
assert.True(t, isHTTPURL("http://example.com/manifest.mf"))
|
||||||
|
assert.True(t, isHTTPURL("https://example.com/manifest.mf"))
|
||||||
|
assert.False(t, isHTTPURL("/local/path.mf"))
|
||||||
|
assert.False(t, isHTTPURL("relative/path.mf"))
|
||||||
|
assert.False(t, isHTTPURL("ftp://example.com/file"))
|
||||||
|
}
|
||||||
374
internal/cli/fetch.go
Normal file
374
internal/cli/fetch.go
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/multiformats/go-multihash"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DownloadProgress reports the progress of a single file download.
|
||||||
|
type DownloadProgress struct {
|
||||||
|
Path string // File path being downloaded
|
||||||
|
BytesRead int64 // Bytes downloaded so far
|
||||||
|
TotalBytes int64 // Total expected bytes (-1 if unknown)
|
||||||
|
BytesPerSec float64 // Current download rate
|
||||||
|
ETA time.Duration // Estimated time to completion
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfa *CLIApp) fetchManifestOperation(ctx *cli.Context) error {
|
||||||
|
log.Debug("fetchManifestOperation()")
|
||||||
|
|
||||||
|
if ctx.Args().Len() == 0 {
|
||||||
|
return fmt.Errorf("URL argument required")
|
||||||
|
}
|
||||||
|
|
||||||
|
inputURL := ctx.Args().Get(0)
|
||||||
|
manifestURL, err := resolveManifestURL(inputURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid URL: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("fetching manifest from %s", manifestURL)
|
||||||
|
|
||||||
|
// Fetch manifest
|
||||||
|
resp, err := http.Get(manifestURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch manifest: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("failed to fetch manifest: HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse manifest
|
||||||
|
manifest, err := mfer.NewManifestFromReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := manifest.Files()
|
||||||
|
log.Infof("manifest contains %d files", len(files))
|
||||||
|
|
||||||
|
// Compute base URL (directory containing manifest)
|
||||||
|
baseURL, err := url.Parse(manifestURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch: invalid manifest URL: %w", err)
|
||||||
|
}
|
||||||
|
baseURL.Path = path.Dir(baseURL.Path)
|
||||||
|
if !strings.HasSuffix(baseURL.Path, "/") {
|
||||||
|
baseURL.Path += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total bytes to download
|
||||||
|
var totalBytes int64
|
||||||
|
for _, f := range files {
|
||||||
|
totalBytes += f.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create progress channel
|
||||||
|
progress := make(chan DownloadProgress, 10)
|
||||||
|
|
||||||
|
// Start progress reporter goroutine
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for p := range progress {
|
||||||
|
rate := formatBitrate(p.BytesPerSec * 8)
|
||||||
|
if p.ETA > 0 {
|
||||||
|
log.Infof("%s: %s/%s, %s, ETA %s",
|
||||||
|
p.Path, humanize.IBytes(uint64(p.BytesRead)), humanize.IBytes(uint64(p.TotalBytes)),
|
||||||
|
rate, p.ETA.Round(time.Second))
|
||||||
|
} else {
|
||||||
|
log.Infof("%s: %s/%s, %s",
|
||||||
|
p.Path, humanize.IBytes(uint64(p.BytesRead)), humanize.IBytes(uint64(p.TotalBytes)), rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Track download start time
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Download each file
|
||||||
|
for _, f := range files {
|
||||||
|
// Sanitize the path to prevent path traversal attacks
|
||||||
|
localPath, err := sanitizePath(f.Path)
|
||||||
|
if err != nil {
|
||||||
|
close(progress)
|
||||||
|
<-done
|
||||||
|
return fmt.Errorf("invalid path in manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileURL := baseURL.String() + encodeFilePath(f.Path)
|
||||||
|
log.Infof("fetching %s", f.Path)
|
||||||
|
|
||||||
|
if err := downloadFile(fileURL, localPath, f, progress); err != nil {
|
||||||
|
close(progress)
|
||||||
|
<-done
|
||||||
|
return fmt.Errorf("failed to download %s: %w", f.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close(progress)
|
||||||
|
<-done
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
avgBytesPerSec := float64(totalBytes) / elapsed.Seconds()
|
||||||
|
avgRate := formatBitrate(avgBytesPerSec * 8)
|
||||||
|
log.Infof("downloaded %d files (%s) in %.1fs (%s avg)",
|
||||||
|
len(files),
|
||||||
|
humanize.IBytes(uint64(totalBytes)),
|
||||||
|
elapsed.Seconds(),
|
||||||
|
avgRate)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeFilePath URL-encodes each segment of a file path while preserving slashes.
|
||||||
|
func encodeFilePath(p string) string {
|
||||||
|
segments := strings.Split(p, "/")
|
||||||
|
for i, seg := range segments {
|
||||||
|
segments[i] = url.PathEscape(seg)
|
||||||
|
}
|
||||||
|
return strings.Join(segments, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizePath validates and sanitizes a file path from the manifest.
|
||||||
|
// It prevents path traversal attacks and rejects unsafe paths.
|
||||||
|
func sanitizePath(p string) (string, error) {
|
||||||
|
// Reject empty paths
|
||||||
|
if p == "" {
|
||||||
|
return "", fmt.Errorf("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject absolute paths
|
||||||
|
if filepath.IsAbs(p) {
|
||||||
|
return "", fmt.Errorf("absolute path not allowed: %s", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the path to resolve . and ..
|
||||||
|
cleaned := filepath.Clean(p)
|
||||||
|
|
||||||
|
// Reject paths that escape the current directory
|
||||||
|
if strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) || cleaned == ".." {
|
||||||
|
return "", fmt.Errorf("path traversal not allowed: %s", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for absolute paths after cleaning (handles edge cases)
|
||||||
|
if filepath.IsAbs(cleaned) {
|
||||||
|
return "", fmt.Errorf("absolute path not allowed: %s", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveManifestURL takes a URL and returns the manifest URL.
|
||||||
|
// If the URL already ends with .mf, it's returned as-is.
|
||||||
|
// Otherwise, index.mf is appended.
|
||||||
|
func resolveManifestURL(inputURL string) (string, error) {
|
||||||
|
parsed, err := url.Parse(inputURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL already ends with .mf
|
||||||
|
if strings.HasSuffix(parsed.Path, ".mf") {
|
||||||
|
return inputURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure path ends with /
|
||||||
|
if !strings.HasSuffix(parsed.Path, "/") {
|
||||||
|
parsed.Path += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append index.mf
|
||||||
|
parsed.Path += "index.mf"
|
||||||
|
|
||||||
|
return parsed.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressWriter wraps an io.Writer and reports progress to a channel.
|
||||||
|
type progressWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
path string
|
||||||
|
total int64
|
||||||
|
written int64
|
||||||
|
startTime time.Time
|
||||||
|
progress chan<- DownloadProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := pw.w.Write(p)
|
||||||
|
pw.written += int64(n)
|
||||||
|
if pw.progress != nil {
|
||||||
|
var bytesPerSec float64
|
||||||
|
var eta time.Duration
|
||||||
|
elapsed := time.Since(pw.startTime)
|
||||||
|
if elapsed > 0 && pw.written > 0 {
|
||||||
|
bytesPerSec = float64(pw.written) / elapsed.Seconds()
|
||||||
|
if bytesPerSec > 0 && pw.total > 0 {
|
||||||
|
remainingBytes := pw.total - pw.written
|
||||||
|
eta = time.Duration(float64(remainingBytes)/bytesPerSec) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendProgress(pw.progress, DownloadProgress{
|
||||||
|
Path: pw.path,
|
||||||
|
BytesRead: pw.written,
|
||||||
|
TotalBytes: pw.total,
|
||||||
|
BytesPerSec: bytesPerSec,
|
||||||
|
ETA: eta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatBitrate formats a bits-per-second value with appropriate unit prefix.
|
||||||
|
func formatBitrate(bps float64) string {
|
||||||
|
switch {
|
||||||
|
case bps >= 1e9:
|
||||||
|
return fmt.Sprintf("%.1f Gbps", bps/1e9)
|
||||||
|
case bps >= 1e6:
|
||||||
|
return fmt.Sprintf("%.1f Mbps", bps/1e6)
|
||||||
|
case bps >= 1e3:
|
||||||
|
return fmt.Sprintf("%.1f Kbps", bps/1e3)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%.0f bps", bps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendProgress sends a progress update without blocking.
|
||||||
|
func sendProgress(ch chan<- DownloadProgress, p DownloadProgress) {
|
||||||
|
select {
|
||||||
|
case ch <- p:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// downloadFile downloads a URL to a local file path with hash verification.
|
||||||
|
// It downloads to a temporary file, verifies the hash, then renames to the final path.
|
||||||
|
// Progress is reported via the progress channel.
|
||||||
|
func downloadFile(fileURL, localPath string, entry *mfer.MFFilePath, progress chan<- DownloadProgress) error {
|
||||||
|
// Create parent directories if needed
|
||||||
|
dir := filepath.Dir(localPath)
|
||||||
|
if dir != "" && dir != "." {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute temp file path in the same directory
|
||||||
|
// For dotfiles, just append .tmp (they're already hidden)
|
||||||
|
// For regular files, prefix with . and append .tmp
|
||||||
|
base := filepath.Base(localPath)
|
||||||
|
var tmpName string
|
||||||
|
if strings.HasPrefix(base, ".") {
|
||||||
|
tmpName = base + ".tmp"
|
||||||
|
} else {
|
||||||
|
tmpName = "." + base + ".tmp"
|
||||||
|
}
|
||||||
|
tmpPath := filepath.Join(dir, tmpName)
|
||||||
|
if dir == "" || dir == "." {
|
||||||
|
tmpPath = tmpName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch file
|
||||||
|
resp, err := http.Get(fileURL) //nolint:gosec // URL constructed from manifest base
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("HTTP request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine expected size
|
||||||
|
expectedSize := entry.Size
|
||||||
|
totalBytes := resp.ContentLength
|
||||||
|
if totalBytes < 0 {
|
||||||
|
totalBytes = expectedSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp file
|
||||||
|
out, err := os.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up hash computation
|
||||||
|
h := sha256.New()
|
||||||
|
|
||||||
|
// Create progress-reporting writer that also computes hash
|
||||||
|
pw := &progressWriter{
|
||||||
|
w: io.MultiWriter(out, h),
|
||||||
|
path: localPath,
|
||||||
|
total: totalBytes,
|
||||||
|
startTime: time.Now(),
|
||||||
|
progress: progress,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy content while hashing and reporting progress
|
||||||
|
written, copyErr := io.Copy(pw, resp.Body)
|
||||||
|
|
||||||
|
// Close file before checking errors (to flush writes)
|
||||||
|
closeErr := out.Close()
|
||||||
|
|
||||||
|
// If copy failed, clean up temp file and return error
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
if closeErr != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return closeErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify size
|
||||||
|
if written != expectedSize {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("size mismatch: expected %d bytes, got %d", expectedSize, written)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode computed hash as multihash
|
||||||
|
computed, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to encode hash: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hash against manifest (at least one must match)
|
||||||
|
hashMatch := false
|
||||||
|
for _, hash := range entry.Hashes {
|
||||||
|
if bytes.Equal(computed, hash.MultiHash) {
|
||||||
|
hashMatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hashMatch {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename temp file to final path
|
||||||
|
if err := os.Rename(tmpPath, localPath); err != nil {
|
||||||
|
_ = os.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
391
internal/cli/fetch_test.go
Normal file
391
internal/cli/fetch_test.go
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEncodeFilePath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"file.txt", "file.txt"},
|
||||||
|
{"dir/file.txt", "dir/file.txt"},
|
||||||
|
{"my file.txt", "my%20file.txt"},
|
||||||
|
{"dir/my file.txt", "dir/my%20file.txt"},
|
||||||
|
{"file#1.txt", "file%231.txt"},
|
||||||
|
{"file?v=1.txt", "file%3Fv=1.txt"},
|
||||||
|
{"path/to/file with spaces.txt", "path/to/file%20with%20spaces.txt"},
|
||||||
|
{"100%done.txt", "100%25done.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
result := encodeFilePath(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizePath(t *testing.T) {
|
||||||
|
// Valid paths that should be accepted
|
||||||
|
validTests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"file.txt", "file.txt"},
|
||||||
|
{"dir/file.txt", "dir/file.txt"},
|
||||||
|
{"dir/subdir/file.txt", "dir/subdir/file.txt"},
|
||||||
|
{"./file.txt", "file.txt"},
|
||||||
|
{"./dir/file.txt", "dir/file.txt"},
|
||||||
|
{"dir/./file.txt", "dir/file.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range validTests {
|
||||||
|
t.Run("valid:"+tt.input, func(t *testing.T) {
|
||||||
|
result, err := sanitizePath(tt.input)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid paths that should be rejected
|
||||||
|
invalidTests := []struct {
|
||||||
|
input string
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{"", "empty path"},
|
||||||
|
{"..", "parent directory"},
|
||||||
|
{"../file.txt", "parent traversal"},
|
||||||
|
{"../../file.txt", "double parent traversal"},
|
||||||
|
{"dir/../../../file.txt", "traversal escaping base"},
|
||||||
|
{"/etc/passwd", "absolute path"},
|
||||||
|
{"/file.txt", "absolute path with single component"},
|
||||||
|
{"dir/../../etc/passwd", "traversal to system file"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range invalidTests {
|
||||||
|
t.Run("invalid:"+tt.desc, func(t *testing.T) {
|
||||||
|
_, err := sanitizePath(tt.input)
|
||||||
|
assert.Error(t, err, "expected error for path: %s", tt.input)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveManifestURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
// Already ends with .mf - use as-is
|
||||||
|
{"https://example.com/path/index.mf", "https://example.com/path/index.mf"},
|
||||||
|
{"https://example.com/path/custom.mf", "https://example.com/path/custom.mf"},
|
||||||
|
{"https://example.com/foo.mf", "https://example.com/foo.mf"},
|
||||||
|
|
||||||
|
// Directory with trailing slash - append index.mf
|
||||||
|
{"https://example.com/path/", "https://example.com/path/index.mf"},
|
||||||
|
{"https://example.com/", "https://example.com/index.mf"},
|
||||||
|
|
||||||
|
// Directory without trailing slash - add slash and index.mf
|
||||||
|
{"https://example.com/path", "https://example.com/path/index.mf"},
|
||||||
|
{"https://example.com", "https://example.com/index.mf"},
|
||||||
|
|
||||||
|
// With query strings
|
||||||
|
{"https://example.com/path?foo=bar", "https://example.com/path/index.mf?foo=bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
result, err := resolveManifestURL(tt.input)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchFromHTTP(t *testing.T) {
|
||||||
|
// Create source filesystem with test files
|
||||||
|
sourceFs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
testFiles := map[string][]byte{
|
||||||
|
"file1.txt": []byte("Hello, World!"),
|
||||||
|
"file2.txt": []byte("This is file 2 with more content."),
|
||||||
|
"subdir/file3.txt": []byte("Nested file content here."),
|
||||||
|
"subdir/deep/f.txt": []byte("Deeply nested file."),
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, content := range testFiles {
|
||||||
|
fullPath := "/" + path // MemMapFs needs absolute paths
|
||||||
|
dir := filepath.Dir(fullPath)
|
||||||
|
require.NoError(t, sourceFs.MkdirAll(dir, 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(sourceFs, fullPath, content, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate manifest using scanner
|
||||||
|
opts := &mfer.ScannerOptions{
|
||||||
|
Fs: sourceFs,
|
||||||
|
}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
|
var manifestBuf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
|
manifestData := manifestBuf.Bytes()
|
||||||
|
|
||||||
|
// Create HTTP server that serves the source filesystem
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
if path == "/index.mf" {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
_, _ = w.Write(manifestData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip leading slash
|
||||||
|
if len(path) > 0 && path[0] == '/' {
|
||||||
|
path = path[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
content, exists := testFiles[path]
|
||||||
|
if !exists {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
_, _ = w.Write(content)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Create destination directory
|
||||||
|
destDir, err := os.MkdirTemp("", "mfer-fetch-test-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.RemoveAll(destDir) }()
|
||||||
|
|
||||||
|
// Change to dest directory for the test
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(destDir))
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Parse the manifest to get file entries
|
||||||
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestData))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
files := manifest.Files()
|
||||||
|
require.Len(t, files, len(testFiles))
|
||||||
|
|
||||||
|
// Download each file using downloadFile
|
||||||
|
progress := make(chan DownloadProgress, 10)
|
||||||
|
go func() {
|
||||||
|
for range progress {
|
||||||
|
// Drain progress channel
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
baseURL := server.URL + "/"
|
||||||
|
for _, f := range files {
|
||||||
|
localPath, err := sanitizePath(f.Path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fileURL := baseURL + f.Path
|
||||||
|
err = downloadFile(fileURL, localPath, f, progress)
|
||||||
|
require.NoError(t, err, "failed to download %s", f.Path)
|
||||||
|
}
|
||||||
|
close(progress)
|
||||||
|
|
||||||
|
// Verify downloaded files match originals
|
||||||
|
for path, expectedContent := range testFiles {
|
||||||
|
downloadedPath := filepath.Join(destDir, path)
|
||||||
|
downloadedContent, err := os.ReadFile(downloadedPath)
|
||||||
|
require.NoError(t, err, "failed to read downloaded file %s", path)
|
||||||
|
assert.Equal(t, expectedContent, downloadedContent, "content mismatch for %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchHashMismatch(t *testing.T) {
|
||||||
|
// Create source filesystem with a test file
|
||||||
|
sourceFs := afero.NewMemMapFs()
|
||||||
|
originalContent := []byte("Original content")
|
||||||
|
require.NoError(t, afero.WriteFile(sourceFs, "/file.txt", originalContent, 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := &mfer.ScannerOptions{Fs: sourceFs}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
|
var manifestBuf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
|
|
||||||
|
// Parse manifest
|
||||||
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
files := manifest.Files()
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
// Create server that serves DIFFERENT content (to trigger hash mismatch)
|
||||||
|
tamperedContent := []byte("Tampered content!")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
_, _ = w.Write(tamperedContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Create temp directory
|
||||||
|
destDir, err := os.MkdirTemp("", "mfer-fetch-hash-test-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.RemoveAll(destDir) }()
|
||||||
|
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(destDir))
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Try to download - should fail with hash mismatch
|
||||||
|
err = downloadFile(server.URL+"/file.txt", "file.txt", files[0], nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "mismatch")
|
||||||
|
|
||||||
|
// Verify temp file was cleaned up
|
||||||
|
_, err = os.Stat(".file.txt.tmp")
|
||||||
|
assert.True(t, os.IsNotExist(err), "temp file should be cleaned up on hash mismatch")
|
||||||
|
|
||||||
|
// Verify final file was not created
|
||||||
|
_, err = os.Stat("file.txt")
|
||||||
|
assert.True(t, os.IsNotExist(err), "final file should not exist on hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSizeMismatch(t *testing.T) {
|
||||||
|
// Create source filesystem with a test file
|
||||||
|
sourceFs := afero.NewMemMapFs()
|
||||||
|
originalContent := []byte("Original content with specific size")
|
||||||
|
require.NoError(t, afero.WriteFile(sourceFs, "/file.txt", originalContent, 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := &mfer.ScannerOptions{Fs: sourceFs}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
|
var manifestBuf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
|
|
||||||
|
// Parse manifest
|
||||||
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
files := manifest.Files()
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
// Create server that serves content with wrong size
|
||||||
|
wrongSizeContent := []byte("Short")
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
_, _ = w.Write(wrongSizeContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Create temp directory
|
||||||
|
destDir, err := os.MkdirTemp("", "mfer-fetch-size-test-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.RemoveAll(destDir) }()
|
||||||
|
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(destDir))
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Try to download - should fail with size mismatch
|
||||||
|
err = downloadFile(server.URL+"/file.txt", "file.txt", files[0], nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "size mismatch")
|
||||||
|
|
||||||
|
// Verify temp file was cleaned up
|
||||||
|
_, err = os.Stat(".file.txt.tmp")
|
||||||
|
assert.True(t, os.IsNotExist(err), "temp file should be cleaned up on size mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchProgress(t *testing.T) {
|
||||||
|
// Create source filesystem with a larger test file
|
||||||
|
sourceFs := afero.NewMemMapFs()
|
||||||
|
// Create content large enough to trigger multiple progress updates
|
||||||
|
content := bytes.Repeat([]byte("x"), 100*1024) // 100KB
|
||||||
|
require.NoError(t, afero.WriteFile(sourceFs, "/large.txt", content, 0o644))
|
||||||
|
|
||||||
|
// Generate manifest
|
||||||
|
opts := &mfer.ScannerOptions{Fs: sourceFs}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumerateFS(sourceFs, "/", nil))
|
||||||
|
|
||||||
|
var manifestBuf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
|
|
||||||
|
// Parse manifest
|
||||||
|
manifest, err := mfer.NewManifestFromReader(bytes.NewReader(manifestBuf.Bytes()))
|
||||||
|
require.NoError(t, err)
|
||||||
|
files := manifest.Files()
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
// Create server that serves the content
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
|
w.Header().Set("Content-Length", "102400")
|
||||||
|
// Write in chunks to allow progress reporting
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
_, _ = io.Copy(w, reader)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Create temp directory
|
||||||
|
destDir, err := os.MkdirTemp("", "mfer-fetch-progress-test-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = os.RemoveAll(destDir) }()
|
||||||
|
|
||||||
|
origDir, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(destDir))
|
||||||
|
defer func() { _ = os.Chdir(origDir) }()
|
||||||
|
|
||||||
|
// Set up progress channel and collect updates
|
||||||
|
progress := make(chan DownloadProgress, 100)
|
||||||
|
var progressUpdates []DownloadProgress
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
for p := range progress {
|
||||||
|
progressUpdates = append(progressUpdates, p)
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Download
|
||||||
|
err = downloadFile(server.URL+"/large.txt", "large.txt", files[0], progress)
|
||||||
|
close(progress)
|
||||||
|
<-done
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify we got progress updates
|
||||||
|
assert.NotEmpty(t, progressUpdates, "should have received progress updates")
|
||||||
|
|
||||||
|
// Verify final progress shows complete
|
||||||
|
if len(progressUpdates) > 0 {
|
||||||
|
last := progressUpdates[len(progressUpdates)-1]
|
||||||
|
assert.Equal(t, int64(len(content)), last.BytesRead, "final progress should show all bytes read")
|
||||||
|
assert.Equal(t, "large.txt", last.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file was downloaded correctly
|
||||||
|
downloaded, err := os.ReadFile("large.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, content, downloaded)
|
||||||
|
}
|
||||||
389
internal/cli/freshen.go
Normal file
389
internal/cli/freshen.go
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/multiformats/go-multihash"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreshenStatus contains progress information for the freshen operation.
|
||||||
|
type FreshenStatus struct {
|
||||||
|
Phase string // "scan" or "hash"
|
||||||
|
TotalFiles int64 // Total files to process in current phase
|
||||||
|
CurrentFiles int64 // Files processed so far
|
||||||
|
TotalBytes int64 // Total bytes to hash (hash phase only)
|
||||||
|
CurrentBytes int64 // Bytes hashed so far
|
||||||
|
BytesPerSec float64 // Current throughput rate
|
||||||
|
ETA time.Duration // Estimated time to completion
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshenEntry tracks a file's status during freshen
|
||||||
|
type freshenEntry struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
mtime time.Time
|
||||||
|
needsHash bool // true if new or changed
|
||||||
|
existing *mfer.MFFilePath // existing manifest entry if unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfa *CLIApp) freshenManifestOperation(ctx *cli.Context) error {
|
||||||
|
log.Debug("freshenManifestOperation()")
|
||||||
|
|
||||||
|
basePath := ctx.String("base")
|
||||||
|
showProgress := ctx.Bool("progress")
|
||||||
|
includeDotfiles := ctx.Bool("include-dotfiles")
|
||||||
|
followSymlinks := ctx.Bool("follow-symlinks")
|
||||||
|
|
||||||
|
// Find manifest file
|
||||||
|
var manifestPath string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if ctx.Args().Len() > 0 {
|
||||||
|
arg := ctx.Args().Get(0)
|
||||||
|
info, statErr := mfa.Fs.Stat(arg)
|
||||||
|
if statErr == nil && info.IsDir() {
|
||||||
|
manifestPath, err = findManifest(mfa.Fs, arg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("freshen: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
manifestPath = arg
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
manifestPath, err = findManifest(mfa.Fs, ".")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("freshen: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("loading manifest from %s", manifestPath)
|
||||||
|
|
||||||
|
// Load existing manifest
|
||||||
|
manifest, err := mfer.NewManifestFromFile(mfa.Fs, manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
existingFiles := manifest.Files()
|
||||||
|
log.Infof("manifest contains %d files", len(existingFiles))
|
||||||
|
|
||||||
|
// Build map of existing entries by path
|
||||||
|
existingByPath := make(map[string]*mfer.MFFilePath, len(existingFiles))
|
||||||
|
for _, f := range existingFiles {
|
||||||
|
existingByPath[f.Path] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Scan filesystem
|
||||||
|
log.Infof("scanning filesystem...")
|
||||||
|
startScan := time.Now()
|
||||||
|
|
||||||
|
var entries []*freshenEntry
|
||||||
|
var scanCount int64
|
||||||
|
var removed, changed, added, unchanged int64
|
||||||
|
|
||||||
|
absBase, err := filepath.Abs(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("freshen: invalid base path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = afero.Walk(mfa.Fs, absBase, func(path string, info fs.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return walkErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath, err := filepath.Rel(absBase, path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("freshen: failed to compute relative path for %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip the manifest file itself
|
||||||
|
if relPath == filepath.Base(manifestPath) || relPath == "."+filepath.Base(manifestPath) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dotfiles
|
||||||
|
if !includeDotfiles && mfer.IsHiddenPath(filepath.ToSlash(relPath)) {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle symlinks
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
if !followSymlinks {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
realPath, err := filepath.EvalSymlinks(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip broken symlinks
|
||||||
|
}
|
||||||
|
realInfo, err := mfa.Fs.Stat(realPath)
|
||||||
|
if err != nil || realInfo.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info = realInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
scanCount++
|
||||||
|
|
||||||
|
// Check against existing manifest
|
||||||
|
existing, inManifest := existingByPath[relPath]
|
||||||
|
if inManifest {
|
||||||
|
// Check if changed (size or mtime)
|
||||||
|
existingMtime := time.Unix(existing.Mtime.Seconds, int64(existing.Mtime.Nanos))
|
||||||
|
if existing.Size != info.Size() || !existingMtime.Equal(info.ModTime()) {
|
||||||
|
changed++
|
||||||
|
log.Verbosef("M %s", relPath)
|
||||||
|
entries = append(entries, &freshenEntry{
|
||||||
|
path: relPath,
|
||||||
|
size: info.Size(),
|
||||||
|
mtime: info.ModTime(),
|
||||||
|
needsHash: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
unchanged++
|
||||||
|
entries = append(entries, &freshenEntry{
|
||||||
|
path: relPath,
|
||||||
|
size: info.Size(),
|
||||||
|
mtime: info.ModTime(),
|
||||||
|
needsHash: false,
|
||||||
|
existing: existing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Mark as seen
|
||||||
|
delete(existingByPath, relPath)
|
||||||
|
} else {
|
||||||
|
added++
|
||||||
|
log.Verbosef("A %s", relPath)
|
||||||
|
entries = append(entries, &freshenEntry{
|
||||||
|
path: relPath,
|
||||||
|
size: info.Size(),
|
||||||
|
mtime: info.ModTime(),
|
||||||
|
needsHash: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report scan progress
|
||||||
|
if showProgress && scanCount%100 == 0 {
|
||||||
|
log.Progressf("Scanning: %d files found", scanCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if showProgress {
|
||||||
|
log.ProgressDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to scan filesystem: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining entries in existingByPath are removed files
|
||||||
|
removed = int64(len(existingByPath))
|
||||||
|
for path := range existingByPath {
|
||||||
|
log.Verbosef("D %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanDuration := time.Since(startScan)
|
||||||
|
log.Infof("scan complete in %s: %d unchanged, %d changed, %d added, %d removed",
|
||||||
|
scanDuration.Round(time.Millisecond), unchanged, changed, added, removed)
|
||||||
|
|
||||||
|
// Calculate total bytes to hash
|
||||||
|
var totalHashBytes int64
|
||||||
|
var filesToHash int64
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.needsHash {
|
||||||
|
totalHashBytes += e.size
|
||||||
|
filesToHash++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Hash changed and new files
|
||||||
|
if filesToHash > 0 {
|
||||||
|
log.Infof("hashing %d files (%s)...", filesToHash, humanize.IBytes(uint64(totalHashBytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
startHash := time.Now()
|
||||||
|
var hashedFiles int64
|
||||||
|
var hashedBytes int64
|
||||||
|
|
||||||
|
builder := mfer.NewBuilder()
|
||||||
|
if ctx.Bool("include-timestamps") {
|
||||||
|
builder.SetIncludeTimestamps(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up signing options if sign-key is provided
|
||||||
|
if signKey := ctx.String("sign-key"); signKey != "" {
|
||||||
|
builder.SetSigningOptions(&mfer.SigningOptions{
|
||||||
|
KeyID: mfer.GPGKeyID(signKey),
|
||||||
|
})
|
||||||
|
log.Infof("signing manifest with GPG key: %s", signKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.needsHash {
|
||||||
|
// Need to read and hash the file
|
||||||
|
absPath := filepath.Join(absBase, e.path)
|
||||||
|
f, err := mfa.Fs.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, bytesRead, err := hashFile(f, e.size, func(n int64) {
|
||||||
|
if showProgress {
|
||||||
|
currentBytes := hashedBytes + n
|
||||||
|
elapsed := time.Since(startHash)
|
||||||
|
var rate float64
|
||||||
|
var eta time.Duration
|
||||||
|
if elapsed > 0 && currentBytes > 0 {
|
||||||
|
rate = float64(currentBytes) / elapsed.Seconds()
|
||||||
|
remaining := totalHashBytes - currentBytes
|
||||||
|
if rate > 0 {
|
||||||
|
eta = time.Duration(float64(remaining)/rate) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if eta > 0 {
|
||||||
|
log.Progressf("Hashing: %d/%d files, %s/s, ETA %s",
|
||||||
|
hashedFiles, filesToHash, humanize.IBytes(uint64(rate)), eta.Round(time.Second))
|
||||||
|
} else {
|
||||||
|
log.Progressf("Hashing: %d/%d files, %s/s",
|
||||||
|
hashedFiles, filesToHash, humanize.IBytes(uint64(rate)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to hash %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedBytes += bytesRead
|
||||||
|
hashedFiles++
|
||||||
|
|
||||||
|
// Add to builder with computed hash
|
||||||
|
if err := addFileToBuilder(builder, e.path, e.size, e.mtime, hash); err != nil {
|
||||||
|
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use existing entry
|
||||||
|
if err := addExistingToBuilder(builder, e.existing); err != nil {
|
||||||
|
return fmt.Errorf("failed to add %s: %w", e.path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showProgress && filesToHash > 0 {
|
||||||
|
log.ProgressDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
log.Infof("freshen complete: %d unchanged, %d changed, %d added, %d removed",
|
||||||
|
unchanged, changed, added, removed)
|
||||||
|
|
||||||
|
// Skip writing if nothing changed
|
||||||
|
if changed == 0 && added == 0 && removed == 0 {
|
||||||
|
log.Infof("manifest unchanged, skipping write")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write updated manifest atomically (write to temp, then rename)
|
||||||
|
tmpPath := manifestPath + ".tmp"
|
||||||
|
outFile, err := mfa.Fs.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = builder.Build(outFile)
|
||||||
|
_ = outFile.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to write manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename temp to final
|
||||||
|
if err := mfa.Fs.Rename(tmpPath, manifestPath); err != nil {
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
return fmt.Errorf("failed to rename manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalDuration := time.Since(mfa.startupTime)
|
||||||
|
if hashedBytes > 0 {
|
||||||
|
hashDuration := time.Since(startHash)
|
||||||
|
hashRate := float64(hashedBytes) / hashDuration.Seconds()
|
||||||
|
log.Infof("hashed %s in %.1fs (%s/s)",
|
||||||
|
humanize.IBytes(uint64(hashedBytes)), totalDuration.Seconds(), humanize.IBytes(uint64(hashRate)))
|
||||||
|
}
|
||||||
|
log.Infof("wrote %d files to %s", len(entries), manifestPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashFile reads a file and computes its SHA256 multihash.
|
||||||
|
// Progress callback is called with bytes read so far.
|
||||||
|
func hashFile(r io.Reader, size int64, progress func(int64)) ([]byte, int64, error) {
|
||||||
|
h := sha256.New()
|
||||||
|
buf := make([]byte, 64*1024)
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := r.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
h.Write(buf[:n])
|
||||||
|
total += int64(n)
|
||||||
|
if progress != nil {
|
||||||
|
progress(total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, total, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
||||||
|
if err != nil {
|
||||||
|
return nil, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mh, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addFileToBuilder adds a new file entry to the builder
|
||||||
|
func addFileToBuilder(b *mfer.Builder, path string, size int64, mtime time.Time, hash []byte) error {
|
||||||
|
return b.AddFileWithHash(mfer.RelFilePath(path), mfer.FileSize(size), mfer.ModTime(mtime), hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// addExistingToBuilder adds an existing manifest entry to the builder
|
||||||
|
func addExistingToBuilder(b *mfer.Builder, entry *mfer.MFFilePath) error {
|
||||||
|
mtime := time.Unix(entry.Mtime.Seconds, int64(entry.Mtime.Nanos))
|
||||||
|
if len(entry.Hashes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return b.AddFileWithHash(mfer.RelFilePath(entry.Path), mfer.FileSize(entry.Size), mfer.ModTime(mtime), entry.Hashes[0].MultiHash)
|
||||||
|
}
|
||||||
82
internal/cli/freshen_test.go
Normal file
82
internal/cli/freshen_test.go
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFreshenUnchanged(t *testing.T) {
|
||||||
|
// Create filesystem with test files
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||||
|
|
||||||
|
// Generate initial manifest
|
||||||
|
opts := &mfer.ScannerOptions{Fs: fs}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
||||||
|
|
||||||
|
var manifestBuf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
|
|
||||||
|
// Write manifest to filesystem
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||||
|
|
||||||
|
// Parse manifest to verify
|
||||||
|
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshenWithChanges(t *testing.T) {
|
||||||
|
// Create filesystem with test files
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||||
|
|
||||||
|
// Generate initial manifest
|
||||||
|
opts := &mfer.ScannerOptions{Fs: fs}
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
||||||
|
|
||||||
|
var manifestBuf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &manifestBuf, nil))
|
||||||
|
|
||||||
|
// Write manifest to filesystem
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.index.mf", manifestBuf.Bytes(), 0o644))
|
||||||
|
|
||||||
|
// Verify initial manifest has 2 files
|
||||||
|
manifest, err := mfer.NewManifestFromFile(fs, "/testdir/.index.mf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, manifest.Files(), 2)
|
||||||
|
|
||||||
|
// Add a new file
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file3.txt", []byte("content3"), 0o644))
|
||||||
|
|
||||||
|
// Modify file2 (change content and size)
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("modified content2"), 0o644))
|
||||||
|
|
||||||
|
// Remove file1
|
||||||
|
require.NoError(t, fs.Remove("/testdir/file1.txt"))
|
||||||
|
|
||||||
|
// Note: The freshen operation would need to be run here
|
||||||
|
// For now, we just verify the test setup is correct
|
||||||
|
exists, _ := afero.Exists(fs, "/testdir/file1.txt")
|
||||||
|
assert.False(t, exists)
|
||||||
|
|
||||||
|
exists, _ = afero.Exists(fs, "/testdir/file3.txt")
|
||||||
|
assert.True(t, exists)
|
||||||
|
|
||||||
|
content, _ := afero.ReadFile(fs, "/testdir/file2.txt")
|
||||||
|
assert.Equal(t, "modified content2", string(content))
|
||||||
|
}
|
||||||
183
internal/cli/gen.go
Normal file
183
internal/cli/gen.go
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (mfa *CLIApp) generateManifestOperation(ctx *cli.Context) error {
|
||||||
|
log.Debug("generateManifestOperation()")
|
||||||
|
|
||||||
|
opts := &mfer.ScannerOptions{
|
||||||
|
IncludeDotfiles: ctx.Bool("include-dotfiles"),
|
||||||
|
FollowSymLinks: ctx.Bool("follow-symlinks"),
|
||||||
|
IncludeTimestamps: ctx.Bool("include-timestamps"),
|
||||||
|
Fs: mfa.Fs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set seed for deterministic UUID if provided
|
||||||
|
if seed := ctx.String("seed"); seed != "" {
|
||||||
|
opts.Seed = seed
|
||||||
|
log.Infof("using deterministic seed for manifest UUID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up signing options if sign-key is provided
|
||||||
|
if signKey := ctx.String("sign-key"); signKey != "" {
|
||||||
|
opts.SigningOptions = &mfer.SigningOptions{
|
||||||
|
KeyID: mfer.GPGKeyID(signKey),
|
||||||
|
}
|
||||||
|
log.Infof("signing manifest with GPG key: %s", signKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := mfer.NewScannerWithOptions(opts)
|
||||||
|
|
||||||
|
// Phase 1: Enumeration - collect paths and stat files
|
||||||
|
args := ctx.Args()
|
||||||
|
showProgress := ctx.Bool("progress")
|
||||||
|
|
||||||
|
// Set up enumeration progress reporting
|
||||||
|
var enumProgress chan mfer.EnumerateStatus
|
||||||
|
var enumWg sync.WaitGroup
|
||||||
|
if showProgress {
|
||||||
|
enumProgress = make(chan mfer.EnumerateStatus, 1)
|
||||||
|
enumWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer enumWg.Done()
|
||||||
|
for status := range enumProgress {
|
||||||
|
log.Progressf("Enumerating: %d files, %s",
|
||||||
|
status.FilesFound,
|
||||||
|
humanize.IBytes(uint64(status.BytesFound)))
|
||||||
|
}
|
||||||
|
log.ProgressDone()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Len() == 0 {
|
||||||
|
// Default to current directory
|
||||||
|
if err := s.EnumeratePath(".", enumProgress); err != nil {
|
||||||
|
return fmt.Errorf("generate: failed to enumerate current directory: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Collect and validate all paths first
|
||||||
|
paths := make([]string, 0, args.Len())
|
||||||
|
for i := 0; i < args.Len(); i++ {
|
||||||
|
inputPath := args.Get(i)
|
||||||
|
ap, err := filepath.Abs(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("generate: invalid path %q: %w", inputPath, err)
|
||||||
|
}
|
||||||
|
// Validate path exists before adding to list
|
||||||
|
if exists, _ := afero.Exists(mfa.Fs, ap); !exists {
|
||||||
|
return fmt.Errorf("path does not exist: %s", inputPath)
|
||||||
|
}
|
||||||
|
log.Debugf("enumerating path: %s", ap)
|
||||||
|
paths = append(paths, ap)
|
||||||
|
}
|
||||||
|
if err := s.EnumeratePaths(enumProgress, paths...); err != nil {
|
||||||
|
return fmt.Errorf("generate: failed to enumerate paths: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enumWg.Wait()
|
||||||
|
|
||||||
|
log.Infof("enumerated %d files, %s total", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())))
|
||||||
|
|
||||||
|
// Check if output file exists
|
||||||
|
outputPath := ctx.String("output")
|
||||||
|
if exists, _ := afero.Exists(mfa.Fs, outputPath); exists {
|
||||||
|
if !ctx.Bool("force") {
|
||||||
|
return fmt.Errorf("output file %s already exists (use --force to overwrite)", outputPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temp file for atomic write
|
||||||
|
tmpPath := outputPath + ".tmp"
|
||||||
|
outFile, err := mfa.Fs.Create(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up signal handler to clean up temp file on Ctrl-C
|
||||||
|
sigChan := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig, ok := <-sigChan
|
||||||
|
if !ok || sig == nil {
|
||||||
|
return // Channel closed normally, not a signal
|
||||||
|
}
|
||||||
|
_ = outFile.Close()
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
os.Exit(1)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Clean up temp file on any error or interruption
|
||||||
|
success := false
|
||||||
|
defer func() {
|
||||||
|
signal.Stop(sigChan)
|
||||||
|
close(sigChan)
|
||||||
|
_ = outFile.Close()
|
||||||
|
if !success {
|
||||||
|
_ = mfa.Fs.Remove(tmpPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Phase 2: Scan - read file contents and generate manifest
|
||||||
|
var scanProgress chan mfer.ScanStatus
|
||||||
|
var scanWg sync.WaitGroup
|
||||||
|
if showProgress {
|
||||||
|
scanProgress = make(chan mfer.ScanStatus, 1)
|
||||||
|
scanWg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer scanWg.Done()
|
||||||
|
for status := range scanProgress {
|
||||||
|
if status.ETA > 0 {
|
||||||
|
log.Progressf("Scanning: %d/%d files, %s/s, ETA %s",
|
||||||
|
status.ScannedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)),
|
||||||
|
status.ETA.Round(time.Second))
|
||||||
|
} else {
|
||||||
|
log.Progressf("Scanning: %d/%d files, %s/s",
|
||||||
|
status.ScannedFiles,
|
||||||
|
status.TotalFiles,
|
||||||
|
humanize.IBytes(uint64(status.BytesPerSec)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.ProgressDone()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.ToManifest(ctx.Context, outFile, scanProgress)
|
||||||
|
scanWg.Wait()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close file before rename to ensure all data is flushed
|
||||||
|
if err := outFile.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic rename
|
||||||
|
if err := mfa.Fs.Rename(tmpPath, outputPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to rename temp file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
success = true
|
||||||
|
|
||||||
|
elapsed := time.Since(mfa.startupTime).Seconds()
|
||||||
|
rate := float64(s.TotalBytes()) / elapsed
|
||||||
|
log.Infof("wrote %d files (%s) to %s in %.1fs (%s/s)", s.FileCount(), humanize.IBytes(uint64(s.TotalBytes())), outputPath, elapsed, humanize.IBytes(uint64(rate)))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
53
internal/cli/list.go
Normal file
53
internal/cli/list.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (mfa *CLIApp) listManifestOperation(ctx *cli.Context) error {
|
||||||
|
// Default to ErrorLevel for clean output
|
||||||
|
log.SetLevel(log.ErrorLevel)
|
||||||
|
|
||||||
|
longFormat := ctx.Bool("long")
|
||||||
|
print0 := ctx.Bool("print0")
|
||||||
|
|
||||||
|
pathOrURL, err := mfa.resolveManifestArg(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := mfa.openManifestReader(pathOrURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rc.Close() }()
|
||||||
|
|
||||||
|
manifest, err := mfer.NewManifestFromReader(rc)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list: failed to parse manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files := manifest.Files()
|
||||||
|
|
||||||
|
// Determine line ending
|
||||||
|
lineEnd := "\n"
|
||||||
|
if print0 {
|
||||||
|
lineEnd = "\x00"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if longFormat {
|
||||||
|
mtime := time.Unix(f.Mtime.Seconds, int64(f.Mtime.Nanos))
|
||||||
|
_, _ = fmt.Fprintf(mfa.Stdout, "%d\t%s\t%s%s", f.Size, mtime.Format(time.RFC3339), f.Path, lineEnd)
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(mfa.Stdout, "%s%s", f.Path, lineEnd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
56
internal/cli/manifest_loader.go
Normal file
56
internal/cli/manifest_loader.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isHTTPURL returns true if the string starts with http:// or https://.
|
||||||
|
func isHTTPURL(s string) bool {
|
||||||
|
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openManifestReader opens a manifest from a path or URL and returns a ReadCloser.
|
||||||
|
// The caller must close the returned reader.
|
||||||
|
func (mfa *CLIApp) openManifestReader(pathOrURL string) (io.ReadCloser, error) {
|
||||||
|
if isHTTPURL(pathOrURL) {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(pathOrURL) //nolint:gosec // user-provided URL is intentional
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch %s: %w", pathOrURL, err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil, fmt.Errorf("failed to fetch %s: HTTP %d", pathOrURL, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return resp.Body, nil
|
||||||
|
}
|
||||||
|
f, err := mfa.Fs.Open(pathOrURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveManifestArg resolves the manifest path from CLI arguments.
|
||||||
|
// HTTP(S) URLs are returned as-is. Directories are searched for index.mf/.index.mf.
|
||||||
|
// If no argument is given, the current directory is searched.
|
||||||
|
func (mfa *CLIApp) resolveManifestArg(ctx *cli.Context) (string, error) {
|
||||||
|
if ctx.Args().Len() > 0 {
|
||||||
|
arg := ctx.Args().Get(0)
|
||||||
|
if isHTTPURL(arg) {
|
||||||
|
return arg, nil
|
||||||
|
}
|
||||||
|
info, statErr := mfa.Fs.Stat(arg)
|
||||||
|
if statErr == nil && info.IsDir() {
|
||||||
|
return findManifest(mfa.Fs, arg)
|
||||||
|
}
|
||||||
|
return arg, nil
|
||||||
|
}
|
||||||
|
return findManifest(mfa.Fs, ".")
|
||||||
|
}
|
||||||
301
internal/cli/mfer.go
Normal file
301
internal/cli/mfer.go
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/urfave/cli/v2"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
"sneak.berlin/go/mfer/mfer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CLIApp is the main CLI application container. It holds configuration,
|
||||||
|
// I/O streams, and filesystem abstraction to enable testing and flexibility.
|
||||||
|
type CLIApp struct {
|
||||||
|
appname string
|
||||||
|
version string
|
||||||
|
gitrev string
|
||||||
|
startupTime time.Time
|
||||||
|
exitCode int
|
||||||
|
app *cli.App
|
||||||
|
|
||||||
|
Stdin io.Reader // Standard input stream
|
||||||
|
Stdout io.Writer // Standard output stream for normal output
|
||||||
|
Stderr io.Writer // Standard error stream for diagnostics
|
||||||
|
Fs afero.Fs // Filesystem abstraction for all file operations
|
||||||
|
}
|
||||||
|
|
||||||
|
const banner = `
|
||||||
|
___ ___ ___ ___
|
||||||
|
/__/\ / /\ / /\ / /\
|
||||||
|
| |::\ / /:/_ / /:/_ / /::\
|
||||||
|
| |:|:\ / /:/ /\ / /:/ /\ / /:/\:\
|
||||||
|
__|__|:|\:\ / /:/ /:/ / /:/ /:/_ / /:/~/:/
|
||||||
|
/__/::::| \:\ /__/:/ /:/ /__/:/ /:/ /\ /__/:/ /:/___
|
||||||
|
\ \:\~~\__\/ \ \:\/:/ \ \:\/:/ /:/ \ \:\/:::::/
|
||||||
|
\ \:\ \ \::/ \ \::/ /:/ \ \::/~~~~
|
||||||
|
\ \:\ \ \:\ \ \:\/:/ \ \:\
|
||||||
|
\ \:\ \ \:\ \ \::/ \ \:\
|
||||||
|
\__\/ \__\/ \__\/ \__\/`
|
||||||
|
|
||||||
|
func (mfa *CLIApp) printBanner() {
|
||||||
|
if log.GetLevel() <= log.InfoLevel {
|
||||||
|
_, _ = fmt.Fprintln(mfa.Stdout, banner)
|
||||||
|
_, _ = fmt.Fprintf(mfa.Stdout, " mfer by @sneak: v%s released %s\n", mfer.Version, mfer.ReleaseDate)
|
||||||
|
_, _ = fmt.Fprintln(mfa.Stdout, " https://sneak.berlin/go/mfer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VersionString returns the version and git revision formatted for display.
|
||||||
|
func (mfa *CLIApp) VersionString() string {
|
||||||
|
if mfa.gitrev != "" {
|
||||||
|
return fmt.Sprintf("%s (%s)", mfer.Version, mfa.gitrev)
|
||||||
|
}
|
||||||
|
return mfer.Version
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfa *CLIApp) setVerbosity(c *cli.Context) {
|
||||||
|
_, present := os.LookupEnv("MFER_DEBUG")
|
||||||
|
if present {
|
||||||
|
log.EnableDebugLogging()
|
||||||
|
} else if c.Bool("quiet") {
|
||||||
|
log.SetLevel(log.ErrorLevel)
|
||||||
|
} else {
|
||||||
|
log.SetLevelFromVerbosity(c.Count("verbose"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// commonFlags returns the flags shared by most commands (-v, -q)
|
||||||
|
func commonFlags() []cli.Flag {
|
||||||
|
return []cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "verbose",
|
||||||
|
Aliases: []string{"v"},
|
||||||
|
Usage: "Increase verbosity (-v for verbose, -vv for debug)",
|
||||||
|
Count: new(int),
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "quiet",
|
||||||
|
Aliases: []string{"q"},
|
||||||
|
Usage: "Suppress output except errors",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mfa *CLIApp) run(args []string) {
|
||||||
|
mfa.startupTime = time.Now()
|
||||||
|
|
||||||
|
if NO_COLOR {
|
||||||
|
// shoutout to rob pike who thinks it's juvenile
|
||||||
|
log.DisableStyling()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure log package to use our I/O streams
|
||||||
|
log.SetOutput(mfa.Stdout, mfa.Stderr)
|
||||||
|
log.Init()
|
||||||
|
|
||||||
|
mfa.app = &cli.App{
|
||||||
|
Name: mfa.appname,
|
||||||
|
Usage: "Manifest generator",
|
||||||
|
Version: mfa.VersionString(),
|
||||||
|
EnableBashCompletion: true,
|
||||||
|
Writer: mfa.Stdout,
|
||||||
|
ErrWriter: mfa.Stderr,
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
if c.Args().Len() > 0 {
|
||||||
|
return fmt.Errorf("unknown command %q", c.Args().First())
|
||||||
|
}
|
||||||
|
mfa.printBanner()
|
||||||
|
return cli.ShowAppHelp(c)
|
||||||
|
},
|
||||||
|
Commands: []*cli.Command{
|
||||||
|
{
|
||||||
|
Name: "generate",
|
||||||
|
Aliases: []string{"gen"},
|
||||||
|
Usage: "Generate manifest file",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
mfa.setVerbosity(c)
|
||||||
|
mfa.printBanner()
|
||||||
|
return mfa.generateManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: append(commonFlags(),
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "follow-symlinks",
|
||||||
|
Aliases: []string{"L"},
|
||||||
|
Usage: "Resolve encountered symlinks",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-dotfiles",
|
||||||
|
Aliases: []string{"IncludeDotfiles"},
|
||||||
|
|
||||||
|
Usage: "Include dot (hidden) files (excluded by default)",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "output",
|
||||||
|
Value: "./.index.mf",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
Usage: "Specify output filename",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "force",
|
||||||
|
Aliases: []string{"f"},
|
||||||
|
Usage: "Overwrite output file if it exists",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "progress",
|
||||||
|
Aliases: []string{"P"},
|
||||||
|
Usage: "Show progress during enumeration and scanning",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "sign-key",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Usage: "GPG key ID to sign the manifest with",
|
||||||
|
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "seed",
|
||||||
|
Usage: "Seed value for deterministic manifest UUID",
|
||||||
|
EnvVars: []string{"MFER_SEED"},
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-timestamps",
|
||||||
|
Usage: "Include createdAt timestamp in manifest (omitted by default for determinism)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "check",
|
||||||
|
Usage: "Validate files using manifest file",
|
||||||
|
ArgsUsage: "[manifest file]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
mfa.setVerbosity(c)
|
||||||
|
mfa.printBanner()
|
||||||
|
return mfa.checkManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: append(commonFlags(),
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "base",
|
||||||
|
Aliases: []string{"b"},
|
||||||
|
Value: ".",
|
||||||
|
Usage: "Base directory for resolving relative paths from manifest",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "progress",
|
||||||
|
Aliases: []string{"P"},
|
||||||
|
Usage: "Show progress during checking",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "no-extra-files",
|
||||||
|
Usage: "Fail if files exist in base directory that are not in manifest",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "require-signature",
|
||||||
|
Aliases: []string{"S"},
|
||||||
|
Usage: "Require manifest to be signed by the specified GPG key ID",
|
||||||
|
EnvVars: []string{"MFER_REQUIRE_SIGNATURE"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "freshen",
|
||||||
|
Usage: "Update manifest with changed, new, and removed files",
|
||||||
|
ArgsUsage: "[manifest file]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
mfa.setVerbosity(c)
|
||||||
|
mfa.printBanner()
|
||||||
|
return mfa.freshenManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: append(commonFlags(),
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "base",
|
||||||
|
Aliases: []string{"b"},
|
||||||
|
Value: ".",
|
||||||
|
Usage: "Base directory for resolving relative paths",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "follow-symlinks",
|
||||||
|
Aliases: []string{"L"},
|
||||||
|
Usage: "Resolve encountered symlinks",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-dotfiles",
|
||||||
|
Aliases: []string{"IncludeDotfiles"},
|
||||||
|
|
||||||
|
Usage: "Include dot (hidden) files (excluded by default)",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "progress",
|
||||||
|
Aliases: []string{"P"},
|
||||||
|
Usage: "Show progress during scanning and hashing",
|
||||||
|
},
|
||||||
|
&cli.StringFlag{
|
||||||
|
Name: "sign-key",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Usage: "GPG key ID to sign the manifest with",
|
||||||
|
EnvVars: []string{"MFER_SIGN_KEY"},
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "include-timestamps",
|
||||||
|
Usage: "Include createdAt timestamp in manifest (omitted by default for determinism)",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "export",
|
||||||
|
Usage: "Export manifest contents as JSON",
|
||||||
|
ArgsUsage: "[manifest file or URL]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return mfa.exportManifestOperation(c)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "version",
|
||||||
|
Usage: "Show version",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
_, _ = fmt.Fprintln(mfa.Stdout, mfa.VersionString())
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Aliases: []string{"ls"},
|
||||||
|
Usage: "List files in manifest",
|
||||||
|
ArgsUsage: "[manifest file]",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
return mfa.listManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: []cli.Flag{
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "long",
|
||||||
|
Aliases: []string{"l"},
|
||||||
|
Usage: "Show size and mtime",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "print0",
|
||||||
|
Usage: "Separate entries with NUL character (for xargs -0)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fetch",
|
||||||
|
Usage: "fetch manifest and referenced files",
|
||||||
|
Action: func(c *cli.Context) error {
|
||||||
|
mfa.setVerbosity(c)
|
||||||
|
mfa.printBanner()
|
||||||
|
return mfa.fetchManifestOperation(c)
|
||||||
|
},
|
||||||
|
Flags: commonFlags(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
mfa.app.HideVersion = false
|
||||||
|
err := mfa.app.Run(args)
|
||||||
|
if err != nil {
|
||||||
|
mfa.exitCode = 1
|
||||||
|
log.WithError(err).Debugf("exiting")
|
||||||
|
}
|
||||||
|
}
|
||||||
274
internal/log/log.go
Normal file
274
internal/log/log.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/apex/log"
|
||||||
|
acli "github.com/apex/log/handlers/cli"
|
||||||
|
"github.com/davecgh/go-spew/spew"
|
||||||
|
"github.com/pterm/pterm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Level represents log severity levels.
|
||||||
|
// Lower values are more verbose.
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DebugLevel is for low-level tracing and structure inspection
|
||||||
|
DebugLevel Level = iota
|
||||||
|
// VerboseLevel is for detailed operational info (file listings, etc)
|
||||||
|
VerboseLevel
|
||||||
|
// InfoLevel is for operational summaries (default)
|
||||||
|
InfoLevel
|
||||||
|
// WarnLevel is for warnings
|
||||||
|
WarnLevel
|
||||||
|
// ErrorLevel is for errors
|
||||||
|
ErrorLevel
|
||||||
|
// FatalLevel is for fatal errors
|
||||||
|
FatalLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l Level) String() string {
|
||||||
|
switch l {
|
||||||
|
case DebugLevel:
|
||||||
|
return "debug"
|
||||||
|
case VerboseLevel:
|
||||||
|
return "verbose"
|
||||||
|
case InfoLevel:
|
||||||
|
return "info"
|
||||||
|
case WarnLevel:
|
||||||
|
return "warn"
|
||||||
|
case ErrorLevel:
|
||||||
|
return "error"
|
||||||
|
case FatalLevel:
|
||||||
|
return "fatal"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// mu protects the output writers and level
|
||||||
|
mu sync.RWMutex
|
||||||
|
// stdout is the writer for progress output
|
||||||
|
stdout io.Writer = os.Stdout
|
||||||
|
// stderr is the writer for log output
|
||||||
|
stderr io.Writer = os.Stderr
|
||||||
|
// currentLevel is our log level (includes Verbose)
|
||||||
|
currentLevel Level = InfoLevel
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetOutput configures the output writers for the log package.
|
||||||
|
// stdout is used for progress output, stderr is used for log messages.
|
||||||
|
func SetOutput(out, err io.Writer) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
stdout = out
|
||||||
|
stderr = err
|
||||||
|
pterm.SetDefaultOutput(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStdout returns the configured stdout writer.
|
||||||
|
func GetStdout() io.Writer {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStderr returns the configured stderr writer.
|
||||||
|
func GetStderr() io.Writer {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableStyling turns off colors and styling for terminal output.
|
||||||
|
func DisableStyling() {
|
||||||
|
pterm.DisableColor()
|
||||||
|
pterm.DisableStyling()
|
||||||
|
pterm.Debug.Prefix.Text = ""
|
||||||
|
pterm.Info.Prefix.Text = ""
|
||||||
|
pterm.Success.Prefix.Text = ""
|
||||||
|
pterm.Warning.Prefix.Text = ""
|
||||||
|
pterm.Error.Prefix.Text = ""
|
||||||
|
pterm.Fatal.Prefix.Text = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the logger with the CLI handler and default log level.
|
||||||
|
func Init() {
|
||||||
|
mu.RLock()
|
||||||
|
w := stderr
|
||||||
|
mu.RUnlock()
|
||||||
|
log.SetHandler(acli.New(w))
|
||||||
|
log.SetLevel(log.DebugLevel) // Let apex/log pass everything; we filter ourselves
|
||||||
|
}
|
||||||
|
|
||||||
|
// isEnabled returns true if messages at the given level should be logged.
|
||||||
|
func isEnabled(l Level) bool {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return l >= currentLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatalf logs a formatted message at fatal level.
|
||||||
|
func Fatalf(format string, args ...interface{}) {
|
||||||
|
if isEnabled(FatalLevel) {
|
||||||
|
log.Fatalf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fatal logs a message at fatal level.
|
||||||
|
func Fatal(arg string) {
|
||||||
|
if isEnabled(FatalLevel) {
|
||||||
|
log.Fatal(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errorf logs a formatted message at error level.
|
||||||
|
func Errorf(format string, args ...interface{}) {
|
||||||
|
if isEnabled(ErrorLevel) {
|
||||||
|
log.Errorf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error logs a message at error level.
|
||||||
|
func Error(arg string) {
|
||||||
|
if isEnabled(ErrorLevel) {
|
||||||
|
log.Error(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warnf logs a formatted message at warn level.
|
||||||
|
func Warnf(format string, args ...interface{}) {
|
||||||
|
if isEnabled(WarnLevel) {
|
||||||
|
log.Warnf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn logs a message at warn level.
|
||||||
|
func Warn(arg string) {
|
||||||
|
if isEnabled(WarnLevel) {
|
||||||
|
log.Warn(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infof logs a formatted message at info level.
|
||||||
|
func Infof(format string, args ...interface{}) {
|
||||||
|
if isEnabled(InfoLevel) {
|
||||||
|
log.Infof(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info logs a message at info level.
|
||||||
|
func Info(arg string) {
|
||||||
|
if isEnabled(InfoLevel) {
|
||||||
|
log.Info(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbosef logs a formatted message at verbose level.
|
||||||
|
func Verbosef(format string, args ...interface{}) {
|
||||||
|
if isEnabled(VerboseLevel) {
|
||||||
|
log.Infof(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verbose logs a message at verbose level.
|
||||||
|
func Verbose(arg string) {
|
||||||
|
if isEnabled(VerboseLevel) {
|
||||||
|
log.Info(arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debugf logs a formatted message at debug level with caller location.
|
||||||
|
func Debugf(format string, args ...interface{}) {
|
||||||
|
if isEnabled(DebugLevel) {
|
||||||
|
DebugReal(fmt.Sprintf(format, args...), 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug logs a message at debug level with caller location.
|
||||||
|
func Debug(arg string) {
|
||||||
|
if isEnabled(DebugLevel) {
|
||||||
|
DebugReal(arg, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DebugReal logs at debug level with caller info from the specified stack depth.
|
||||||
|
func DebugReal(arg string, cs int) {
|
||||||
|
if !isEnabled(DebugLevel) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, callerFile, callerLine, ok := runtime.Caller(cs)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag := fmt.Sprintf("%s:%d: ", filepath.Base(callerFile), callerLine)
|
||||||
|
log.Debug(tag + arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dump logs a spew dump of the arguments at debug level.
|
||||||
|
func Dump(args ...interface{}) {
|
||||||
|
if isEnabled(DebugLevel) {
|
||||||
|
DebugReal(spew.Sdump(args...), 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableDebugLogging sets the log level to debug.
|
||||||
|
func EnableDebugLogging() {
|
||||||
|
SetLevel(DebugLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerbosityStepsToLogLevel converts a -v count to a log level.
|
||||||
|
// 0 returns InfoLevel, 1 returns VerboseLevel, 2+ returns DebugLevel.
|
||||||
|
func VerbosityStepsToLogLevel(l int) Level {
|
||||||
|
switch l {
|
||||||
|
case 0:
|
||||||
|
return InfoLevel
|
||||||
|
case 1:
|
||||||
|
return VerboseLevel
|
||||||
|
default:
|
||||||
|
return DebugLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevelFromVerbosity sets the log level based on -v flag count.
|
||||||
|
func SetLevelFromVerbosity(l int) {
|
||||||
|
SetLevel(VerbosityStepsToLogLevel(l))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLevel sets the global log level.
|
||||||
|
func SetLevel(l Level) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
currentLevel = l
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLevel returns the current log level.
|
||||||
|
func GetLevel() Level {
|
||||||
|
mu.RLock()
|
||||||
|
defer mu.RUnlock()
|
||||||
|
return currentLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithError returns a log entry with the error attached.
|
||||||
|
func WithError(e error) *log.Entry {
|
||||||
|
return log.Log.WithError(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progressf prints a progress message that overwrites the current line.
|
||||||
|
// Use ProgressDone() when progress is complete to move to the next line.
|
||||||
|
func Progressf(format string, args ...interface{}) {
|
||||||
|
pterm.Printf("\r"+format, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressDone clears the progress line when progress is complete.
|
||||||
|
func ProgressDone() {
|
||||||
|
// Clear the line with spaces and return to beginning
|
||||||
|
pterm.Print("\r\033[K")
|
||||||
|
}
|
||||||
12
internal/log/log_test.go
Normal file
12
internal/log/log_test.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild(t *testing.T) {
|
||||||
|
Init()
|
||||||
|
assert.True(t, true)
|
||||||
|
}
|
||||||
281
mfer/builder.go
Normal file
281
mfer/builder.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/multiformats/go-multihash"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidatePath checks that a file path conforms to manifest path invariants:
|
||||||
|
// - Must be valid UTF-8
|
||||||
|
// - Must use forward slashes only (no backslashes)
|
||||||
|
// - Must be relative (no leading /)
|
||||||
|
// - Must not contain ".." segments
|
||||||
|
// - Must not contain empty segments (no "//")
|
||||||
|
// - Must not be empty
|
||||||
|
func ValidatePath(p string) error {
|
||||||
|
if p == "" {
|
||||||
|
return errors.New("path cannot be empty")
|
||||||
|
}
|
||||||
|
if !utf8.ValidString(p) {
|
||||||
|
return fmt.Errorf("path %q is not valid UTF-8", p)
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(p, '\\') {
|
||||||
|
return fmt.Errorf("path %q contains backslash; use forward slashes only", p)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(p, "/") {
|
||||||
|
return fmt.Errorf("path %q is absolute; must be relative", p)
|
||||||
|
}
|
||||||
|
for _, seg := range strings.Split(p, "/") {
|
||||||
|
if seg == "" {
|
||||||
|
return fmt.Errorf("path %q contains empty segment", p)
|
||||||
|
}
|
||||||
|
if seg == ".." {
|
||||||
|
return fmt.Errorf("path %q contains '..' segment", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelFilePath represents a relative file path within a manifest.
|
||||||
|
type RelFilePath string
|
||||||
|
|
||||||
|
// AbsFilePath represents an absolute file path on the filesystem.
|
||||||
|
type AbsFilePath string
|
||||||
|
|
||||||
|
// FileSize represents the size of a file in bytes.
|
||||||
|
type FileSize int64
|
||||||
|
|
||||||
|
// FileCount represents a count of files.
|
||||||
|
type FileCount int64
|
||||||
|
|
||||||
|
// ModTime represents a file's modification time.
|
||||||
|
type ModTime time.Time
|
||||||
|
|
||||||
|
// UnixSeconds represents seconds since Unix epoch.
|
||||||
|
type UnixSeconds int64
|
||||||
|
|
||||||
|
// UnixNanos represents the nanosecond component of a timestamp (0-999999999).
|
||||||
|
type UnixNanos int32
|
||||||
|
|
||||||
|
// Timestamp converts ModTime to a protobuf Timestamp.
|
||||||
|
func (m ModTime) Timestamp() *Timestamp {
|
||||||
|
t := time.Time(m)
|
||||||
|
return &Timestamp{
|
||||||
|
Seconds: t.Unix(),
|
||||||
|
Nanos: int32(t.Nanosecond()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multihash represents a multihash-encoded file hash (typically SHA2-256).
|
||||||
|
type Multihash []byte
|
||||||
|
|
||||||
|
// FileHashProgress reports progress during file hashing.
|
||||||
|
type FileHashProgress struct {
|
||||||
|
BytesRead FileSize // Total bytes read so far for the current file
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builder constructs a manifest by adding files one at a time.
|
||||||
|
type Builder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
files []*MFFilePath
|
||||||
|
createdAt time.Time
|
||||||
|
includeTimestamps bool
|
||||||
|
signingOptions *SigningOptions
|
||||||
|
fixedUUID []byte // if set, use this UUID instead of generating one
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSeed derives a deterministic UUID from the given seed string.
|
||||||
|
// The seed is hashed once with SHA-256 and the first 16 bytes are used
|
||||||
|
// as a fixed UUID for the manifest.
|
||||||
|
func (b *Builder) SetSeed(seed string) {
|
||||||
|
hash := sha256.Sum256([]byte(seed))
|
||||||
|
b.fixedUUID = hash[:16]
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuilder creates a new Builder.
|
||||||
|
func NewBuilder() *Builder {
|
||||||
|
return &Builder{
|
||||||
|
files: make([]*MFFilePath, 0),
|
||||||
|
createdAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFile reads file content from reader, computes hashes, and adds to manifest.
|
||||||
|
// Progress updates are sent to the progress channel (if non-nil) without blocking.
|
||||||
|
// Returns the number of bytes read.
|
||||||
|
func (b *Builder) AddFile(
|
||||||
|
path RelFilePath,
|
||||||
|
size FileSize,
|
||||||
|
mtime ModTime,
|
||||||
|
reader io.Reader,
|
||||||
|
progress chan<- FileHashProgress,
|
||||||
|
) (FileSize, error) {
|
||||||
|
if err := ValidatePath(string(path)); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create hash writer
|
||||||
|
h := sha256.New()
|
||||||
|
|
||||||
|
// Read file in chunks, updating hash and progress
|
||||||
|
var totalRead FileSize
|
||||||
|
buf := make([]byte, 64*1024) // 64KB chunks
|
||||||
|
|
||||||
|
for {
|
||||||
|
n, err := reader.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
h.Write(buf[:n])
|
||||||
|
totalRead += FileSize(n)
|
||||||
|
sendFileHashProgress(progress, FileHashProgress{BytesRead: totalRead})
|
||||||
|
}
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return totalRead, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify actual bytes read matches declared size
|
||||||
|
if totalRead != size {
|
||||||
|
return totalRead, fmt.Errorf("size mismatch for %q: declared %d bytes but read %d bytes", path, size, totalRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode hash as multihash (SHA2-256)
|
||||||
|
mh, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
||||||
|
if err != nil {
|
||||||
|
return totalRead, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file entry
|
||||||
|
entry := &MFFilePath{
|
||||||
|
Path: string(path),
|
||||||
|
Size: int64(size),
|
||||||
|
Hashes: []*MFFileChecksum{
|
||||||
|
{MultiHash: mh},
|
||||||
|
},
|
||||||
|
Mtime: mtime.Timestamp(),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.files = append(b.files, entry)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return totalRead, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendFileHashProgress sends a progress update without blocking.
|
||||||
|
func sendFileHashProgress(ch chan<- FileHashProgress, p FileHashProgress) {
|
||||||
|
if ch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- p:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileCount returns the number of files added to the builder.
|
||||||
|
func (b *Builder) FileCount() int {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return len(b.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFileWithHash adds a file entry with a pre-computed hash.
|
||||||
|
// This is useful when the hash is already known (e.g., from an existing manifest).
|
||||||
|
// Returns an error if path is empty, size is negative, or hash is nil/empty.
|
||||||
|
func (b *Builder) AddFileWithHash(path RelFilePath, size FileSize, mtime ModTime, hash Multihash) error {
|
||||||
|
if err := ValidatePath(string(path)); err != nil {
|
||||||
|
return fmt.Errorf("add file: %w", err)
|
||||||
|
}
|
||||||
|
if size < 0 {
|
||||||
|
return errors.New("size cannot be negative")
|
||||||
|
}
|
||||||
|
if len(hash) == 0 {
|
||||||
|
return errors.New("hash cannot be nil or empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &MFFilePath{
|
||||||
|
Path: string(path),
|
||||||
|
Size: int64(size),
|
||||||
|
Hashes: []*MFFileChecksum{
|
||||||
|
{MultiHash: hash},
|
||||||
|
},
|
||||||
|
Mtime: mtime.Timestamp(),
|
||||||
|
}
|
||||||
|
|
||||||
|
b.mu.Lock()
|
||||||
|
b.files = append(b.files, entry)
|
||||||
|
b.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetIncludeTimestamps controls whether the manifest includes a createdAt timestamp.
|
||||||
|
// By default timestamps are omitted for deterministic output.
|
||||||
|
func (b *Builder) SetIncludeTimestamps(include bool) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.includeTimestamps = include
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSigningOptions sets the GPG signing options for the manifest.
|
||||||
|
// If opts is non-nil, the manifest will be signed when Build() is called.
|
||||||
|
func (b *Builder) SetSigningOptions(opts *SigningOptions) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.signingOptions = opts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build finalizes the manifest and writes it to the writer.
|
||||||
|
func (b *Builder) Build(w io.Writer) error {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
|
// Sort files by path for deterministic output
|
||||||
|
sort.Slice(b.files, func(i, j int) bool {
|
||||||
|
return b.files[i].Path < b.files[j].Path
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create inner manifest
|
||||||
|
inner := &MFFile{
|
||||||
|
Version: MFFile_VERSION_ONE,
|
||||||
|
Files: b.files,
|
||||||
|
}
|
||||||
|
if b.includeTimestamps {
|
||||||
|
inner.CreatedAt = newTimestampFromTime(b.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary manifest to use existing serialization
|
||||||
|
m := &manifest{
|
||||||
|
pbInner: inner,
|
||||||
|
signingOptions: b.signingOptions,
|
||||||
|
fixedUUID: b.fixedUUID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate outer wrapper
|
||||||
|
if err := m.generateOuter(); err != nil {
|
||||||
|
return fmt.Errorf("build: generate outer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate final output
|
||||||
|
if err := m.generate(); err != nil {
|
||||||
|
return fmt.Errorf("build: generate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write to output
|
||||||
|
_, err := w.Write(m.output.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build: write output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
387
mfer/builder_test.go
Normal file
387
mfer/builder_test.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewBuilder(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
assert.NotNil(t, b)
|
||||||
|
assert.Equal(t, 0, b.FileCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderAddFile(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
content := []byte("test content")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
|
bytesRead, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, FileSize(len(content)), bytesRead)
|
||||||
|
assert.Equal(t, 1, b.FileCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderAddFileWithHash(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
hash := make([]byte, 34) // SHA256 multihash is 34 bytes
|
||||||
|
|
||||||
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, b.FileCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderAddFileWithHashValidation(t *testing.T) {
|
||||||
|
t.Run("empty path", func(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
hash := make([]byte, 34)
|
||||||
|
err := b.AddFileWithHash("", 100, ModTime(time.Now()), hash)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "path")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("negative size", func(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
hash := make([]byte, 34)
|
||||||
|
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), hash)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "size")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nil hash", func(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hash")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty hash", func(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), []byte{})
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "hash")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid inputs", func(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
hash := make([]byte, 34)
|
||||||
|
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 1, b.FileCount())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderBuild(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
content := []byte("test content")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should have magic bytes
|
||||||
|
assert.True(t, strings.HasPrefix(buf.String(), MAGIC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewTimestampFromTimeExtremeDate(t *testing.T) {
|
||||||
|
// Regression test: newTimestampFromTime used UnixNano() which panics
|
||||||
|
// for dates outside ~1678-2262. Now uses Nanosecond() which is safe.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
time time.Time
|
||||||
|
}{
|
||||||
|
{"zero time", time.Time{}},
|
||||||
|
{"year 1000", time.Date(1000, 1, 1, 0, 0, 0, 0, time.UTC)},
|
||||||
|
{"year 3000", time.Date(3000, 1, 1, 0, 0, 0, 123456789, time.UTC)},
|
||||||
|
{"unix epoch", time.Unix(0, 0)},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Should not panic
|
||||||
|
ts := newTimestampFromTime(tt.time)
|
||||||
|
assert.Equal(t, tt.time.Unix(), ts.Seconds)
|
||||||
|
assert.Equal(t, int32(tt.time.Nanosecond()), ts.Nanos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderDeterministicOutput(t *testing.T) {
|
||||||
|
buildManifest := func() []byte {
|
||||||
|
b := NewBuilder()
|
||||||
|
// Use a fixed createdAt and UUID so output is reproducible
|
||||||
|
b.createdAt = time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
b.fixedUUID = make([]byte, 16) // all zeros
|
||||||
|
|
||||||
|
mtime := ModTime(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC))
|
||||||
|
|
||||||
|
// Add files in reverse order to test sorting
|
||||||
|
files := []struct {
|
||||||
|
path string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{"c/file.txt", "content c"},
|
||||||
|
{"a/file.txt", "content a"},
|
||||||
|
{"b/file.txt", "content b"},
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
r := bytes.NewReader([]byte(f.content))
|
||||||
|
_, err := b.AddFile(RelFilePath(f.path), FileSize(len(f.content)), mtime, r, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := b.Build(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
out1 := buildManifest()
|
||||||
|
out2 := buildManifest()
|
||||||
|
assert.Equal(t, out1, out2, "two builds with same input should produce byte-identical output")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSeedDeterministic(t *testing.T) {
|
||||||
|
b1 := NewBuilder()
|
||||||
|
b1.SetSeed("test-seed-value")
|
||||||
|
b2 := NewBuilder()
|
||||||
|
b2.SetSeed("test-seed-value")
|
||||||
|
assert.Equal(t, b1.fixedUUID, b2.fixedUUID, "same seed should produce same UUID")
|
||||||
|
assert.Len(t, b1.fixedUUID, 16, "UUID should be 16 bytes")
|
||||||
|
|
||||||
|
b3 := NewBuilder()
|
||||||
|
b3.SetSeed("different-seed")
|
||||||
|
assert.NotEqual(t, b1.fixedUUID, b3.fixedUUID, "different seeds should produce different UUIDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePath(t *testing.T) {
|
||||||
|
valid := []string{
|
||||||
|
"file.txt",
|
||||||
|
"dir/file.txt",
|
||||||
|
"a/b/c/d.txt",
|
||||||
|
"file with spaces.txt",
|
||||||
|
"日本語.txt",
|
||||||
|
}
|
||||||
|
for _, p := range valid {
|
||||||
|
t.Run("valid:"+p, func(t *testing.T) {
|
||||||
|
assert.NoError(t, ValidatePath(p))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []struct {
|
||||||
|
path string
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{"", "empty"},
|
||||||
|
{"/absolute", "absolute path"},
|
||||||
|
{"has\\backslash", "backslash"},
|
||||||
|
{"has/../traversal", "dot-dot segment"},
|
||||||
|
{"has//double", "empty segment"},
|
||||||
|
{"..", "just dot-dot"},
|
||||||
|
{string([]byte{0xff, 0xfe}), "invalid UTF-8"},
|
||||||
|
}
|
||||||
|
for _, tt := range invalid {
|
||||||
|
t.Run("invalid:"+tt.desc, func(t *testing.T) {
|
||||||
|
assert.Error(t, ValidatePath(tt.path))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderAddFileSizeMismatch(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
content := []byte("short")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
|
// Declare wrong size
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(100), ModTime(time.Now()), reader, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "size mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderAddFileInvalidPath(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
content := []byte("data")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
|
||||||
|
_, err := b.AddFile("", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
|
||||||
|
reader.Reset(content)
|
||||||
|
_, err = b.AddFile("/absolute", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderAddFileWithProgress(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
content := bytes.Repeat([]byte("x"), 1000)
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
progress := make(chan FileHashProgress, 100)
|
||||||
|
|
||||||
|
bytesRead, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, progress)
|
||||||
|
close(progress)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, FileSize(1000), bytesRead)
|
||||||
|
|
||||||
|
var updates []FileHashProgress
|
||||||
|
for p := range progress {
|
||||||
|
updates = append(updates, p)
|
||||||
|
}
|
||||||
|
assert.NotEmpty(t, updates)
|
||||||
|
// Last update should show all bytes
|
||||||
|
assert.Equal(t, FileSize(1000), updates[len(updates)-1].BytesRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderBuildRoundTrip(t *testing.T) {
|
||||||
|
// Build a manifest, deserialize it, verify all fields survive round-trip
|
||||||
|
b := NewBuilder()
|
||||||
|
now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
files := []struct {
|
||||||
|
path string
|
||||||
|
content []byte
|
||||||
|
}{
|
||||||
|
{"alpha.txt", []byte("alpha content")},
|
||||||
|
{"beta/gamma.txt", []byte("gamma content")},
|
||||||
|
{"beta/delta.txt", []byte("delta content")},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
reader := bytes.NewReader(f.content)
|
||||||
|
_, err := b.AddFile(RelFilePath(f.path), FileSize(len(f.content)), ModTime(now), reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mfiles := m.Files()
|
||||||
|
require.Len(t, mfiles, 3)
|
||||||
|
|
||||||
|
// Verify sorted order
|
||||||
|
assert.Equal(t, "alpha.txt", mfiles[0].Path)
|
||||||
|
assert.Equal(t, "beta/delta.txt", mfiles[1].Path)
|
||||||
|
assert.Equal(t, "beta/gamma.txt", mfiles[2].Path)
|
||||||
|
|
||||||
|
// Verify sizes
|
||||||
|
assert.Equal(t, int64(len("alpha content")), mfiles[0].Size)
|
||||||
|
|
||||||
|
// Verify hashes are present
|
||||||
|
for _, f := range mfiles {
|
||||||
|
require.NotEmpty(t, f.Hashes, "file %s should have hashes", f.Path)
|
||||||
|
assert.NotEmpty(t, f.Hashes[0].MultiHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewManifestFromReaderInvalidMagic(t *testing.T) {
|
||||||
|
_, err := NewManifestFromReader(bytes.NewReader([]byte("NOT_VALID")))
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "invalid file format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewManifestFromReaderEmpty(t *testing.T) {
|
||||||
|
_, err := NewManifestFromReader(bytes.NewReader([]byte{}))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewManifestFromReaderTruncated(t *testing.T) {
|
||||||
|
// Just the magic with nothing after
|
||||||
|
_, err := NewManifestFromReader(bytes.NewReader([]byte(MAGIC)))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestString(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
content := []byte("test")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, m.String(), "count=1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderBuildEmpty(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := b.Build(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should still produce valid manifest with 0 files
|
||||||
|
assert.True(t, strings.HasPrefix(buf.String(), MAGIC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderOmitsCreatedAtByDefault(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
content := []byte("hello")
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), bytes.NewReader(content), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, m.pbInner.CreatedAt, "createdAt should be nil by default for deterministic output")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) {
|
||||||
|
b := NewBuilder()
|
||||||
|
b.SetIncludeTimestamps(true)
|
||||||
|
content := []byte("hello")
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime(time.Now()), bytes.NewReader(content), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, b.Build(&buf))
|
||||||
|
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, m.pbInner.CreatedAt, "createdAt should be set when IncludeTimestamps is true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderDeterministicFileOrder(t *testing.T) {
|
||||||
|
// Two builds with same files in different order should produce same file ordering.
|
||||||
|
// Note: UUIDs differ per build, so we compare parsed file lists, not raw bytes.
|
||||||
|
buildAndParse := func(order []string) []*MFFilePath {
|
||||||
|
b := NewBuilder()
|
||||||
|
for _, name := range order {
|
||||||
|
content := []byte("content of " + name)
|
||||||
|
_, err := b.AddFile(RelFilePath(name), FileSize(len(content)), ModTime(time.Unix(1000, 0)), bytes.NewReader(content), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, b.Build(&buf))
|
||||||
|
m, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return m.Files()
|
||||||
|
}
|
||||||
|
|
||||||
|
files1 := buildAndParse([]string{"b.txt", "a.txt"})
|
||||||
|
files2 := buildAndParse([]string{"a.txt", "b.txt"})
|
||||||
|
|
||||||
|
require.Len(t, files1, 2)
|
||||||
|
require.Len(t, files2, 2)
|
||||||
|
for i := range files1 {
|
||||||
|
assert.Equal(t, files1[i].Path, files2[i].Path)
|
||||||
|
assert.Equal(t, files1[i].Size, files2[i].Size)
|
||||||
|
}
|
||||||
|
assert.Equal(t, "a.txt", files1[0].Path)
|
||||||
|
assert.Equal(t, "b.txt", files1[1].Path)
|
||||||
|
}
|
||||||
362
mfer/checker.go
Normal file
362
mfer/checker.go
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/multiformats/go-multihash"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Result represents the outcome of checking a single file.
|
||||||
|
type Result struct {
|
||||||
|
Path RelFilePath // Relative path from manifest
|
||||||
|
Status Status // Verification result status
|
||||||
|
Message string // Human-readable description of the result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status represents the verification status of a file.
|
||||||
|
type Status int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusOK Status = iota // File matches manifest (size and hash verified)
|
||||||
|
StatusMissing // File not found on disk
|
||||||
|
StatusSizeMismatch // File size differs from manifest
|
||||||
|
StatusHashMismatch // File hash differs from manifest
|
||||||
|
StatusExtra // File exists on disk but not in manifest
|
||||||
|
StatusError // Error occurred during verification
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Status) String() string {
|
||||||
|
switch s {
|
||||||
|
case StatusOK:
|
||||||
|
return "OK"
|
||||||
|
case StatusMissing:
|
||||||
|
return "MISSING"
|
||||||
|
case StatusSizeMismatch:
|
||||||
|
return "SIZE_MISMATCH"
|
||||||
|
case StatusHashMismatch:
|
||||||
|
return "HASH_MISMATCH"
|
||||||
|
case StatusExtra:
|
||||||
|
return "EXTRA"
|
||||||
|
case StatusError:
|
||||||
|
return "ERROR"
|
||||||
|
default:
|
||||||
|
return "UNKNOWN"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckStatus contains progress information for the check operation.
|
||||||
|
type CheckStatus struct {
|
||||||
|
TotalFiles FileCount // Total number of files in manifest
|
||||||
|
CheckedFiles FileCount // Number of files checked so far
|
||||||
|
TotalBytes FileSize // Total bytes to verify (sum of all file sizes)
|
||||||
|
CheckedBytes FileSize // Bytes verified so far
|
||||||
|
BytesPerSec float64 // Current throughput rate
|
||||||
|
ETA time.Duration // Estimated time to completion
|
||||||
|
Failures FileCount // Number of verification failures encountered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checker verifies files against a manifest.
|
||||||
|
type Checker struct {
|
||||||
|
basePath AbsFilePath
|
||||||
|
files []*MFFilePath
|
||||||
|
fs afero.Fs
|
||||||
|
// manifestPaths is a set of paths in the manifest for quick lookup
|
||||||
|
manifestPaths map[RelFilePath]struct{}
|
||||||
|
// manifestRelPath is the relative path of the manifest file from basePath (for exclusion)
|
||||||
|
manifestRelPath RelFilePath
|
||||||
|
// signature info from the manifest
|
||||||
|
signature []byte
|
||||||
|
signer []byte
|
||||||
|
signingPubKey []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChecker creates a new Checker for the given manifest, base path, and filesystem.
|
||||||
|
// The basePath is the directory relative to which manifest paths are resolved.
|
||||||
|
// If fs is nil, the real filesystem (OsFs) is used.
|
||||||
|
func NewChecker(manifestPath string, basePath string, fs afero.Fs) (*Checker, error) {
|
||||||
|
if fs == nil {
|
||||||
|
fs = afero.NewOsFs()
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := NewManifestFromFile(fs, manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
abs, err := filepath.Abs(basePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
files := m.Files()
|
||||||
|
manifestPaths := make(map[RelFilePath]struct{}, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
manifestPaths[RelFilePath(f.Path)] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute manifest's relative path from basePath for exclusion in FindExtraFiles
|
||||||
|
absManifest, err := filepath.Abs(manifestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
manifestRel, err := filepath.Rel(abs, absManifest)
|
||||||
|
if err != nil {
|
||||||
|
manifestRel = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Checker{
|
||||||
|
basePath: AbsFilePath(abs),
|
||||||
|
files: files,
|
||||||
|
fs: fs,
|
||||||
|
manifestPaths: manifestPaths,
|
||||||
|
manifestRelPath: RelFilePath(manifestRel),
|
||||||
|
signature: m.pbOuter.Signature,
|
||||||
|
signer: m.pbOuter.Signer,
|
||||||
|
signingPubKey: m.pbOuter.SigningPubKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileCount returns the number of files in the manifest.
|
||||||
|
func (c *Checker) FileCount() FileCount {
|
||||||
|
return FileCount(len(c.files))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalBytes returns the total size of all files in the manifest.
|
||||||
|
func (c *Checker) TotalBytes() FileSize {
|
||||||
|
var total FileSize
|
||||||
|
for _, f := range c.files {
|
||||||
|
total += FileSize(f.Size)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSigned returns true if the manifest has a signature.
|
||||||
|
func (c *Checker) IsSigned() bool {
|
||||||
|
return len(c.signature) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signer returns the signer fingerprint if the manifest is signed, nil otherwise.
|
||||||
|
func (c *Checker) Signer() []byte {
|
||||||
|
return c.signer
|
||||||
|
}
|
||||||
|
|
||||||
|
// SigningPubKey returns the signing public key if the manifest is signed, nil otherwise.
|
||||||
|
func (c *Checker) SigningPubKey() []byte {
|
||||||
|
return c.signingPubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractEmbeddedSigningKeyFP imports the manifest's embedded public key into a
|
||||||
|
// temporary keyring and extracts its fingerprint. This validates the key and
|
||||||
|
// returns its actual fingerprint from the key material itself.
|
||||||
|
func (c *Checker) ExtractEmbeddedSigningKeyFP() (string, error) {
|
||||||
|
if len(c.signingPubKey) == 0 {
|
||||||
|
return "", errors.New("manifest has no signing public key")
|
||||||
|
}
|
||||||
|
return gpgExtractPubKeyFingerprint(c.signingPubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check verifies all files against the manifest.
|
||||||
|
// Results are sent to the results channel as files are checked.
|
||||||
|
// Progress updates are sent to the progress channel approximately once per second.
|
||||||
|
// Both channels are closed when the method returns.
|
||||||
|
func (c *Checker) Check(ctx context.Context, results chan<- Result, progress chan<- CheckStatus) error {
|
||||||
|
if results != nil {
|
||||||
|
defer close(results)
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
defer close(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalFiles := FileCount(len(c.files))
|
||||||
|
totalBytes := c.TotalBytes()
|
||||||
|
|
||||||
|
var checkedFiles FileCount
|
||||||
|
var checkedBytes FileSize
|
||||||
|
var failures FileCount
|
||||||
|
|
||||||
|
startTime := time.Now()
|
||||||
|
lastProgressTime := time.Now()
|
||||||
|
|
||||||
|
for _, entry := range c.files {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
result := c.checkFile(entry, &checkedBytes)
|
||||||
|
if result.Status != StatusOK {
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
checkedFiles++
|
||||||
|
|
||||||
|
if results != nil {
|
||||||
|
results <- result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send progress at most once per second (rate-limited)
|
||||||
|
if progress != nil {
|
||||||
|
now := time.Now()
|
||||||
|
isLast := checkedFiles == totalFiles
|
||||||
|
if isLast || now.Sub(lastProgressTime) >= time.Second {
|
||||||
|
elapsed := time.Since(startTime)
|
||||||
|
var bytesPerSec float64
|
||||||
|
var eta time.Duration
|
||||||
|
|
||||||
|
if elapsed > 0 && checkedBytes > 0 {
|
||||||
|
bytesPerSec = float64(checkedBytes) / elapsed.Seconds()
|
||||||
|
remainingBytes := totalBytes - checkedBytes
|
||||||
|
if bytesPerSec > 0 {
|
||||||
|
eta = time.Duration(float64(remainingBytes)/bytesPerSec) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCheckStatus(progress, CheckStatus{
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
CheckedFiles: checkedFiles,
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
CheckedBytes: checkedBytes,
|
||||||
|
BytesPerSec: bytesPerSec,
|
||||||
|
ETA: eta,
|
||||||
|
Failures: failures,
|
||||||
|
})
|
||||||
|
lastProgressTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
|
||||||
|
absPath := filepath.Join(string(c.basePath), entry.Path)
|
||||||
|
relPath := RelFilePath(entry.Path)
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
info, err := c.fs.Stat(absPath)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) || errors.Is(err, afero.ErrFileNotFound) {
|
||||||
|
return Result{Path: relPath, Status: StatusMissing, Message: "file not found"}
|
||||||
|
}
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check size
|
||||||
|
if info.Size() != entry.Size {
|
||||||
|
*checkedBytes += FileSize(info.Size())
|
||||||
|
return Result{
|
||||||
|
Path: relPath,
|
||||||
|
Status: StatusSizeMismatch,
|
||||||
|
Message: "size mismatch",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open and hash file
|
||||||
|
f, err := c.fs.Open(absPath)
|
||||||
|
if err != nil {
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
|
||||||
|
h := sha256.New()
|
||||||
|
n, err := io.Copy(h, f)
|
||||||
|
if err != nil {
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
*checkedBytes += FileSize(n)
|
||||||
|
|
||||||
|
// Encode as multihash and compare
|
||||||
|
computed, err := multihash.Encode(h.Sum(nil), multihash.SHA2_256)
|
||||||
|
if err != nil {
|
||||||
|
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against all hashes in manifest (at least one must match)
|
||||||
|
for _, hash := range entry.Hashes {
|
||||||
|
if bytes.Equal(computed, hash.MultiHash) {
|
||||||
|
return Result{Path: relPath, Status: StatusOK}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result{Path: relPath, Status: StatusHashMismatch, Message: "hash mismatch"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindExtraFiles walks the filesystem and reports files not in the manifest.
|
||||||
|
// Results are sent to the results channel. The channel is closed when done.
|
||||||
|
// Hidden files/directories (starting with .) are skipped, as they are excluded
|
||||||
|
// from manifests by default. The manifest file itself is also skipped.
|
||||||
|
func (c *Checker) FindExtraFiles(ctx context.Context, results chan<- Result) error {
|
||||||
|
if results != nil {
|
||||||
|
defer close(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
return afero.Walk(c.fs, string(c.basePath), func(walkPath string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
rel, err := filepath.Rel(string(c.basePath), walkPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip hidden files and directories (dotfiles)
|
||||||
|
if IsHiddenPath(filepath.ToSlash(rel)) {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath := RelFilePath(rel)
|
||||||
|
|
||||||
|
// Skip the manifest file itself
|
||||||
|
if relPath == c.manifestRelPath {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if path is in manifest
|
||||||
|
if _, exists := c.manifestPaths[relPath]; !exists {
|
||||||
|
if results != nil {
|
||||||
|
results <- Result{
|
||||||
|
Path: relPath,
|
||||||
|
Status: StatusExtra,
|
||||||
|
Message: "not in manifest",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCheckStatus sends a status update without blocking.
|
||||||
|
func sendCheckStatus(ch chan<- CheckStatus, status CheckStatus) {
|
||||||
|
if ch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- status:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
568
mfer/checker_test.go
Normal file
568
mfer/checker_test.go
Normal file
@@ -0,0 +1,568 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStatusString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
status Status
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{StatusOK, "OK"},
|
||||||
|
{StatusMissing, "MISSING"},
|
||||||
|
{StatusSizeMismatch, "SIZE_MISMATCH"},
|
||||||
|
{StatusHashMismatch, "HASH_MISMATCH"},
|
||||||
|
{StatusExtra, "EXTRA"},
|
||||||
|
{StatusError, "ERROR"},
|
||||||
|
{Status(99), "UNKNOWN"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.expected, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, tt.status.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestManifest creates a manifest file in the filesystem with the given files.
|
||||||
|
func createTestManifest(t *testing.T, fs afero.Fs, manifestPath string, files map[string][]byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
builder := NewBuilder()
|
||||||
|
for path, content := range files {
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
_, err := builder.AddFile(RelFilePath(path), FileSize(len(content)), ModTime(time.Now()), reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, builder.Build(&buf))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, manifestPath, buf.Bytes(), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
// createFilesOnDisk creates the given files on the filesystem.
|
||||||
|
func createFilesOnDisk(t *testing.T, fs afero.Fs, basePath string, files map[string][]byte) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for path, content := range files {
|
||||||
|
fullPath := basePath + "/" + path
|
||||||
|
require.NoError(t, fs.MkdirAll(basePath, 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewChecker(t *testing.T) {
|
||||||
|
t.Run("valid manifest", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file1.txt": []byte("hello"),
|
||||||
|
"file2.txt": []byte("world"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, chk)
|
||||||
|
assert.Equal(t, FileCount(2), chk.FileCount())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing manifest", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
_, err := NewChecker("/nonexistent.mf", "/", fs)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid manifest", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/bad.mf", []byte("not a manifest"), 0o644))
|
||||||
|
_, err := NewChecker("/bad.mf", "/", fs)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckerFileCountAndTotalBytes(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"small.txt": []byte("hi"),
|
||||||
|
"medium.txt": []byte("hello world"),
|
||||||
|
"large.txt": bytes.Repeat([]byte("x"), 1000),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(3), chk.FileCount())
|
||||||
|
assert.Equal(t, FileSize(2+11+1000), chk.TotalBytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAllFilesOK(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file1.txt": []byte("content one"),
|
||||||
|
"file2.txt": []byte("content two"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.Check(context.Background(), results, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var resultList []Result
|
||||||
|
for r := range results {
|
||||||
|
resultList = append(resultList, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, resultList, 2)
|
||||||
|
for _, r := range resultList {
|
||||||
|
assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMissingFile(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"exists.txt": []byte("I exist"),
|
||||||
|
"missing.txt": []byte("I don't exist on disk"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
// Only create one file
|
||||||
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
|
"exists.txt": []byte("I exist"),
|
||||||
|
})
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.Check(context.Background(), results, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var okCount, missingCount int
|
||||||
|
for r := range results {
|
||||||
|
switch r.Status {
|
||||||
|
case StatusOK:
|
||||||
|
okCount++
|
||||||
|
case StatusMissing:
|
||||||
|
missingCount++
|
||||||
|
assert.Equal(t, RelFilePath("missing.txt"), r.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 1, okCount)
|
||||||
|
assert.Equal(t, 1, missingCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSizeMismatch(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file.txt": []byte("original content"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
// Create file with different size
|
||||||
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
|
"file.txt": []byte("short"),
|
||||||
|
})
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.Check(context.Background(), results, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
r := <-results
|
||||||
|
assert.Equal(t, StatusSizeMismatch, r.Status)
|
||||||
|
assert.Equal(t, RelFilePath("file.txt"), r.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckHashMismatch(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
originalContent := []byte("original content")
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file.txt": originalContent,
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
// Create file with same size but different content
|
||||||
|
differentContent := []byte("different contnt") // same length (16 bytes) but different
|
||||||
|
require.Equal(t, len(originalContent), len(differentContent), "test requires same length")
|
||||||
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
|
"file.txt": differentContent,
|
||||||
|
})
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.Check(context.Background(), results, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
r := <-results
|
||||||
|
assert.Equal(t, StatusHashMismatch, r.Status)
|
||||||
|
assert.Equal(t, RelFilePath("file.txt"), r.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckWithProgress(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file1.txt": bytes.Repeat([]byte("a"), 100),
|
||||||
|
"file2.txt": bytes.Repeat([]byte("b"), 200),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
progress := make(chan CheckStatus, 10)
|
||||||
|
|
||||||
|
err = chk.Check(context.Background(), results, progress)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Drain results
|
||||||
|
for range results {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check progress was sent
|
||||||
|
var progressUpdates []CheckStatus
|
||||||
|
for p := range progress {
|
||||||
|
progressUpdates = append(progressUpdates, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEmpty(t, progressUpdates)
|
||||||
|
// Final progress should show all files checked
|
||||||
|
final := progressUpdates[len(progressUpdates)-1]
|
||||||
|
assert.Equal(t, FileCount(2), final.TotalFiles)
|
||||||
|
assert.Equal(t, FileCount(2), final.CheckedFiles)
|
||||||
|
assert.Equal(t, FileSize(300), final.TotalBytes)
|
||||||
|
assert.Equal(t, FileSize(300), final.CheckedBytes)
|
||||||
|
assert.Equal(t, FileCount(0), final.Failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckContextCancellation(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
// Create many files to ensure we have time to cancel
|
||||||
|
files := make(map[string][]byte)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
files[string(rune('a'+i%26))+".txt"] = bytes.Repeat([]byte("x"), 1000)
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
results := make(chan Result, 200)
|
||||||
|
err = chk.Check(ctx, results, nil)
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtraFiles(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
// Manifest only contains file1
|
||||||
|
manifestFiles := map[string][]byte{
|
||||||
|
"file1.txt": []byte("in manifest"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", manifestFiles)
|
||||||
|
|
||||||
|
// Disk has file1 and file2
|
||||||
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
|
"file1.txt": []byte("in manifest"),
|
||||||
|
"file2.txt": []byte("extra file"),
|
||||||
|
})
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.FindExtraFiles(context.Background(), results)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var extras []Result
|
||||||
|
for r := range results {
|
||||||
|
extras = append(extras, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, extras, 1)
|
||||||
|
assert.Equal(t, RelFilePath("file2.txt"), extras[0].Path)
|
||||||
|
assert.Equal(t, StatusExtra, extras[0].Status)
|
||||||
|
assert.Equal(t, "not in manifest", extras[0].Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtraFilesSkipsManifestAndDotfiles(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
manifestFiles := map[string][]byte{
|
||||||
|
"file1.txt": []byte("in manifest"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/data/.index.mf", manifestFiles)
|
||||||
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
|
"file1.txt": []byte("in manifest"),
|
||||||
|
})
|
||||||
|
// Create dotfile and manifest that should be skipped
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("hidden"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.config/settings", []byte("cfg"), 0o644))
|
||||||
|
// Create a real extra file
|
||||||
|
require.NoError(t, fs.MkdirAll("/data", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/extra.txt", []byte("extra"), 0o644))
|
||||||
|
|
||||||
|
chk, err := NewChecker("/data/.index.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.FindExtraFiles(context.Background(), results)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var extras []Result
|
||||||
|
for r := range results {
|
||||||
|
extras = append(extras, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should only report extra.txt, not .hidden, .config/settings, or .index.mf
|
||||||
|
for _, e := range extras {
|
||||||
|
t.Logf("extra: %s", e.Path)
|
||||||
|
}
|
||||||
|
assert.Len(t, extras, 1)
|
||||||
|
if len(extras) > 0 {
|
||||||
|
assert.Equal(t, RelFilePath("extra.txt"), extras[0].Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtraFilesContextCancellation(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{"file.txt": []byte("data")}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.FindExtraFiles(ctx, results)
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckNilChannels(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{"file.txt": []byte("data")}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not panic with nil channels
|
||||||
|
err = chk.Check(context.Background(), nil, nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtraFilesNilChannel(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{"file.txt": []byte("data")}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not panic with nil channel
|
||||||
|
err = chk.FindExtraFiles(context.Background(), nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckSubdirectories(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"dir1/file1.txt": []byte("content1"),
|
||||||
|
"dir1/dir2/file2.txt": []byte("content2"),
|
||||||
|
"dir1/dir2/dir3/deep.txt": []byte("deep content"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
|
||||||
|
// Create files with full directory structure
|
||||||
|
for path, content := range files {
|
||||||
|
fullPath := "/data/" + path
|
||||||
|
require.NoError(t, fs.MkdirAll("/data/dir1/dir2/dir3", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, fullPath, content, 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.Check(context.Background(), results, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var okCount int
|
||||||
|
for r := range results {
|
||||||
|
assert.Equal(t, StatusOK, r.Status, "file %s should be OK", r.Path)
|
||||||
|
okCount++
|
||||||
|
}
|
||||||
|
assert.Equal(t, 3, okCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMissingFileDetectedWithoutFallback(t *testing.T) {
|
||||||
|
// Regression test: errors.Is(err, errors.New("...")) never matches because
|
||||||
|
// errors.New creates a new value each time. The fix uses os.ErrNotExist instead.
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"exists.txt": []byte("here"),
|
||||||
|
"missing.txt": []byte("not on disk"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
// Only create one file on disk
|
||||||
|
createFilesOnDisk(t, fs, "/data", map[string][]byte{
|
||||||
|
"exists.txt": []byte("here"),
|
||||||
|
})
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.Check(context.Background(), results, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
statusCounts := map[Status]int{}
|
||||||
|
for r := range results {
|
||||||
|
statusCounts[r.Status]++
|
||||||
|
if r.Status == StatusMissing {
|
||||||
|
assert.Equal(t, RelFilePath("missing.txt"), r.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, statusCounts[StatusOK], "one file should be OK")
|
||||||
|
assert.Equal(t, 1, statusCounts[StatusMissing], "one file should be MISSING")
|
||||||
|
assert.Equal(t, 0, statusCounts[StatusError], "no files should be ERROR")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtraFilesSkipsDotfiles(t *testing.T) {
|
||||||
|
// Regression test for #16: FindExtraFiles should not report dotfiles
|
||||||
|
// or the manifest file itself as extra files.
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file1.txt": []byte("in manifest"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/data/.index.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
// Add dotfiles and manifest file on disk
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.hidden", []byte("dotfile"), 0o644))
|
||||||
|
require.NoError(t, fs.MkdirAll("/data/.git", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/data/.git/config", []byte("git config"), 0o644))
|
||||||
|
|
||||||
|
chk, err := NewChecker("/data/.index.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.FindExtraFiles(context.Background(), results)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var extras []Result
|
||||||
|
for r := range results {
|
||||||
|
extras = append(extras, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should report NO extra files — dotfiles and manifest should be skipped
|
||||||
|
assert.Empty(t, extras, "FindExtraFiles should not report dotfiles or manifest file as extra; got: %v", extras)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindExtraFilesSkipsManifestFile(t *testing.T) {
|
||||||
|
// The manifest file itself should never be reported as extra
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := map[string][]byte{
|
||||||
|
"file1.txt": []byte("content"),
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/data/index.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/data/index.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.FindExtraFiles(context.Background(), results)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var extras []Result
|
||||||
|
for r := range results {
|
||||||
|
extras = append(extras, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, extras, "manifest file should not be reported as extra; got: %v", extras)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckEmptyManifest(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
// Create manifest with no files
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", map[string][]byte{})
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(0), chk.FileCount())
|
||||||
|
assert.Equal(t, FileSize(0), chk.TotalBytes())
|
||||||
|
|
||||||
|
results := make(chan Result, 10)
|
||||||
|
err = chk.Check(context.Background(), results, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var count int
|
||||||
|
for range results {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
assert.Equal(t, 0, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckProgressRateLimited(t *testing.T) {
|
||||||
|
// Create many small files - progress should be rate-limited, not one per file.
|
||||||
|
// With rate-limiting to once per second, we should get far fewer progress
|
||||||
|
// updates than files (plus one final update).
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
files := make(map[string][]byte, 100)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
name := fmt.Sprintf("file%03d.txt", i)
|
||||||
|
files[name] = []byte("content")
|
||||||
|
}
|
||||||
|
createTestManifest(t, fs, "/manifest.mf", files)
|
||||||
|
createFilesOnDisk(t, fs, "/data", files)
|
||||||
|
|
||||||
|
chk, err := NewChecker("/manifest.mf", "/data", fs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
results := make(chan Result, 200)
|
||||||
|
progress := make(chan CheckStatus, 200)
|
||||||
|
err = chk.Check(context.Background(), results, progress)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Drain results
|
||||||
|
for range results {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count progress updates
|
||||||
|
var progressCount int
|
||||||
|
for range progress {
|
||||||
|
progressCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be far fewer than 100 (rate-limited to once per second)
|
||||||
|
// At minimum we get the final update
|
||||||
|
assert.GreaterOrEqual(t, progressCount, 1, "should get at least the final progress update")
|
||||||
|
assert.Less(t, progressCount, 100, "progress should be rate-limited, not one per file")
|
||||||
|
}
|
||||||
11
mfer/constants.go
Normal file
11
mfer/constants.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
const (
|
||||||
|
Version = "0.1.0"
|
||||||
|
ReleaseDate = "2025-12-17"
|
||||||
|
|
||||||
|
// MaxDecompressedSize is the maximum allowed size of decompressed manifest
|
||||||
|
// data (256 MB). This prevents decompression bombs from consuming excessive
|
||||||
|
// memory.
|
||||||
|
MaxDecompressedSize int64 = 256 * 1024 * 1024
|
||||||
|
)
|
||||||
172
mfer/deserialize.go
Normal file
172
mfer/deserialize.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
"sneak.berlin/go/mfer/internal/bork"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validateUUID checks that the byte slice is a valid UUID (16 bytes, parseable).
|
||||||
|
func validateUUID(data []byte) error {
|
||||||
|
if len(data) != 16 {
|
||||||
|
return errors.New("invalid UUID length")
|
||||||
|
}
|
||||||
|
// Try to parse as UUID to validate format
|
||||||
|
_, err := uuid.FromBytes(data)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid UUID format")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifest) deserializeInner() error {
|
||||||
|
if m.pbOuter.Version != MFFileOuter_VERSION_ONE {
|
||||||
|
return errors.New("unknown version")
|
||||||
|
}
|
||||||
|
if m.pbOuter.CompressionType != MFFileOuter_COMPRESSION_ZSTD {
|
||||||
|
return errors.New("unknown compression type")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate outer UUID before any decompression
|
||||||
|
if err := validateUUID(m.pbOuter.Uuid); err != nil {
|
||||||
|
return errors.New("outer UUID invalid: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify hash of compressed data before decompression
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := h.Write(m.pbOuter.InnerMessage); err != nil {
|
||||||
|
return fmt.Errorf("deserialize: hash write: %w", err)
|
||||||
|
}
|
||||||
|
sha256Hash := h.Sum(nil)
|
||||||
|
if !bytes.Equal(sha256Hash, m.pbOuter.Sha256) {
|
||||||
|
return errors.New("compressed data hash mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature if present
|
||||||
|
if len(m.pbOuter.Signature) > 0 {
|
||||||
|
if len(m.pbOuter.SigningPubKey) == 0 {
|
||||||
|
return errors.New("signature present but no public key")
|
||||||
|
}
|
||||||
|
|
||||||
|
sigString, err := m.signatureString()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate signature string for verification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := gpgVerify([]byte(sigString), m.pbOuter.Signature, m.pbOuter.SigningPubKey); err != nil {
|
||||||
|
return fmt.Errorf("signature verification failed: %w", err)
|
||||||
|
}
|
||||||
|
log.Infof("signature verified successfully")
|
||||||
|
}
|
||||||
|
|
||||||
|
bb := bytes.NewBuffer(m.pbOuter.InnerMessage)
|
||||||
|
|
||||||
|
zr, err := zstd.NewReader(bb)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deserialize: zstd reader: %w", err)
|
||||||
|
}
|
||||||
|
defer zr.Close()
|
||||||
|
|
||||||
|
// Limit decompressed size to prevent decompression bombs.
|
||||||
|
// Use declared size + 1 byte to detect overflow, capped at MaxDecompressedSize.
|
||||||
|
maxSize := MaxDecompressedSize
|
||||||
|
if m.pbOuter.Size > 0 && m.pbOuter.Size < int64(maxSize) {
|
||||||
|
maxSize = int64(m.pbOuter.Size) + 1
|
||||||
|
}
|
||||||
|
limitedReader := io.LimitReader(zr, maxSize)
|
||||||
|
dat, err := io.ReadAll(limitedReader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deserialize: decompress: %w", err)
|
||||||
|
}
|
||||||
|
if int64(len(dat)) >= MaxDecompressedSize {
|
||||||
|
return fmt.Errorf("decompressed data exceeds maximum allowed size of %d bytes", MaxDecompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
isize := len(dat)
|
||||||
|
if int64(isize) != m.pbOuter.Size {
|
||||||
|
log.Debugf("truncated data, got %d expected %d", isize, m.pbOuter.Size)
|
||||||
|
return bork.ErrFileTruncated
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize inner message
|
||||||
|
m.pbInner = new(MFFile)
|
||||||
|
if err := proto.Unmarshal(dat, m.pbInner); err != nil {
|
||||||
|
return fmt.Errorf("deserialize: unmarshal inner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate inner UUID
|
||||||
|
if err := validateUUID(m.pbInner.Uuid); err != nil {
|
||||||
|
return errors.New("inner UUID invalid: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify UUIDs match
|
||||||
|
if !bytes.Equal(m.pbOuter.Uuid, m.pbInner.Uuid) {
|
||||||
|
return errors.New("outer and inner UUID mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("loaded manifest with %d files", len(m.pbInner.Files))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateMagic(dat []byte) bool {
|
||||||
|
ml := len([]byte(MAGIC))
|
||||||
|
if len(dat) < ml {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
got := dat[0:ml]
|
||||||
|
expected := []byte(MAGIC)
|
||||||
|
return bytes.Equal(got, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManifestFromReader reads a manifest from an io.Reader.
|
||||||
|
func NewManifestFromReader(input io.Reader) (*manifest, error) {
|
||||||
|
m := &manifest{}
|
||||||
|
dat, err := io.ReadAll(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !validateMagic(dat) {
|
||||||
|
return nil, errors.New("invalid file format")
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove magic bytes prefix:
|
||||||
|
ml := len([]byte(MAGIC))
|
||||||
|
bb := bytes.NewBuffer(dat[ml:])
|
||||||
|
dat = bb.Bytes()
|
||||||
|
|
||||||
|
// deserialize outer:
|
||||||
|
m.pbOuter = new(MFFileOuter)
|
||||||
|
if err := proto.Unmarshal(dat, m.pbOuter); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// deserialize inner:
|
||||||
|
if err := m.deserializeInner(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManifestFromFile reads a manifest from a file path using the given filesystem.
|
||||||
|
// If fs is nil, the real filesystem (OsFs) is used.
|
||||||
|
func NewManifestFromFile(fs afero.Fs, path string) (*manifest, error) {
|
||||||
|
if fs == nil {
|
||||||
|
fs = afero.NewOsFs()
|
||||||
|
}
|
||||||
|
f, err := fs.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
return NewManifestFromReader(f)
|
||||||
|
}
|
||||||
212
mfer/gpg.go
Normal file
212
mfer/gpg.go
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPGKeyID represents a GPG key identifier (fingerprint or key ID).
|
||||||
|
type GPGKeyID string
|
||||||
|
|
||||||
|
// SigningOptions contains options for GPG signing.
|
||||||
|
type SigningOptions struct {
|
||||||
|
KeyID GPGKeyID
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpgSign creates a detached signature of the data using the specified key.
|
||||||
|
// Returns the armored detached signature.
|
||||||
|
func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||||
|
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
|
"--detach-sign",
|
||||||
|
"--armor",
|
||||||
|
"--local-user", string(keyID),
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd.Stdin = bytes.NewReader(data)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||||
|
// Returns the armored public key.
|
||||||
|
func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||||
|
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
|
"--export",
|
||||||
|
"--armor",
|
||||||
|
string(keyID),
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("gpg export failed: %w: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if stdout.Len() == 0 {
|
||||||
|
return nil, fmt.Errorf("gpg key not found: %s", keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
||||||
|
func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
|
||||||
|
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
|
"--with-colons",
|
||||||
|
"--fingerprint",
|
||||||
|
string(keyID),
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("gpg fingerprint lookup failed: %w: %s", err, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the colon-delimited output to find the fingerprint
|
||||||
|
lines := strings.Split(stdout.String(), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||||
|
return []byte(fields[9]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("fingerprint not found for key: %s", keyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
||||||
|
// and extracts its fingerprint. This verifies the key is valid and returns
|
||||||
|
// the actual fingerprint from the key material.
|
||||||
|
func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||||
|
// Create temporary directory for GPG operations
|
||||||
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
|
// Set restrictive permissions
|
||||||
|
if err := os.Chmod(tmpDir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to set temp dir permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write public key to temp file
|
||||||
|
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
||||||
|
if err := os.WriteFile(pubKeyFile, pubKey, 0o600); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the public key into the temporary keyring
|
||||||
|
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
|
"--homedir", tmpDir,
|
||||||
|
"--import",
|
||||||
|
pubKeyFile,
|
||||||
|
)
|
||||||
|
var importStderr bytes.Buffer
|
||||||
|
importCmd.Stderr = &importStderr
|
||||||
|
if err := importCmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to import public key: %w: %s", err, importStderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// List keys to get fingerprint
|
||||||
|
listCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
|
"--homedir", tmpDir,
|
||||||
|
"--with-colons",
|
||||||
|
"--fingerprint",
|
||||||
|
)
|
||||||
|
var listStdout, listStderr bytes.Buffer
|
||||||
|
listCmd.Stdout = &listStdout
|
||||||
|
listCmd.Stderr = &listStderr
|
||||||
|
if err := listCmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to list keys: %w: %s", err, listStderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the colon-delimited output to find the fingerprint
|
||||||
|
lines := strings.Split(listStdout.String(), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||||
|
return fields[9], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("fingerprint not found in imported key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpgVerify verifies a detached signature against data using the provided public key.
|
||||||
|
// It creates a temporary keyring to import the public key for verification.
|
||||||
|
func gpgVerify(data, signature, pubKey []byte) error {
|
||||||
|
// Create temporary directory for GPG operations
|
||||||
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create temp dir: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||||
|
|
||||||
|
// Set restrictive permissions
|
||||||
|
if err := os.Chmod(tmpDir, 0o700); err != nil {
|
||||||
|
return fmt.Errorf("failed to set temp dir permissions: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write public key to temp file
|
||||||
|
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
||||||
|
if err := os.WriteFile(pubKeyFile, pubKey, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write signature to temp file
|
||||||
|
sigFile := filepath.Join(tmpDir, "signature.asc")
|
||||||
|
if err := os.WriteFile(sigFile, signature, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data to temp file
|
||||||
|
dataFile := filepath.Join(tmpDir, "data")
|
||||||
|
if err := os.WriteFile(dataFile, data, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("failed to write data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the public key into the temporary keyring
|
||||||
|
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
|
"--homedir", tmpDir,
|
||||||
|
"--import",
|
||||||
|
pubKeyFile,
|
||||||
|
)
|
||||||
|
var importStderr bytes.Buffer
|
||||||
|
importCmd.Stderr = &importStderr
|
||||||
|
if err := importCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("failed to import public key: %w: %s", err, importStderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
verifyCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||||
|
"--homedir", tmpDir,
|
||||||
|
"--verify",
|
||||||
|
sigFile,
|
||||||
|
dataFile,
|
||||||
|
)
|
||||||
|
var verifyStderr bytes.Buffer
|
||||||
|
verifyCmd.Stderr = &verifyStderr
|
||||||
|
if err := verifyCmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("signature verification failed: %w: %s", err, verifyStderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
347
mfer/gpg_test.go
Normal file
347
mfer/gpg_test.go
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testGPGEnv sets up a temporary GPG home directory with a test key.
|
||||||
|
// Returns the key ID and a cleanup function.
|
||||||
|
func testGPGEnv(t *testing.T) (GPGKeyID, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Check if gpg is installed
|
||||||
|
if _, err := exec.LookPath("gpg"); err != nil {
|
||||||
|
t.Skip("gpg not installed, skipping signing test")
|
||||||
|
return "", func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create temporary GPG home directory
|
||||||
|
gpgHome, err := os.MkdirTemp("", "mfer-gpg-test-*")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set restrictive permissions on GPG home
|
||||||
|
require.NoError(t, os.Chmod(gpgHome, 0o700))
|
||||||
|
|
||||||
|
// Save original GNUPGHOME and set new one
|
||||||
|
origGPGHome := os.Getenv("GNUPGHOME")
|
||||||
|
require.NoError(t, os.Setenv("GNUPGHOME", gpgHome))
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
if origGPGHome == "" {
|
||||||
|
_ = os.Unsetenv("GNUPGHOME")
|
||||||
|
} else {
|
||||||
|
_ = os.Setenv("GNUPGHOME", origGPGHome)
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(gpgHome)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a test key with no passphrase
|
||||||
|
keyParams := `%no-protection
|
||||||
|
Key-Type: RSA
|
||||||
|
Key-Length: 2048
|
||||||
|
Name-Real: MFER Test Key
|
||||||
|
Name-Email: test@mfer.test
|
||||||
|
Expire-Date: 0
|
||||||
|
%commit
|
||||||
|
`
|
||||||
|
paramsFile := filepath.Join(gpgHome, "key-params")
|
||||||
|
require.NoError(t, os.WriteFile(paramsFile, []byte(keyParams), 0o600))
|
||||||
|
|
||||||
|
cmd := exec.Command("gpg", "--batch", "--gen-key", paramsFile)
|
||||||
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Skipf("failed to generate test GPG key: %v: %s", err, output)
|
||||||
|
return "", func() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the key fingerprint
|
||||||
|
cmd = exec.Command("gpg", "--list-keys", "--with-colons", "test@mfer.test")
|
||||||
|
cmd.Env = append(os.Environ(), "GNUPGHOME="+gpgHome)
|
||||||
|
output, err = cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
cleanup()
|
||||||
|
t.Fatalf("failed to list test key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse fingerprint from output
|
||||||
|
var keyID string
|
||||||
|
for _, line := range strings.Split(string(output), "\n") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||||
|
keyID = fields[9]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyID == "" {
|
||||||
|
cleanup()
|
||||||
|
t.Fatal("failed to find test key fingerprint")
|
||||||
|
}
|
||||||
|
|
||||||
|
return GPGKeyID(keyID), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGPGSign(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
data := []byte("test data to sign")
|
||||||
|
sig, err := gpgSign(data, keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, sig)
|
||||||
|
assert.Contains(t, string(sig), "-----BEGIN PGP SIGNATURE-----")
|
||||||
|
assert.Contains(t, string(sig), "-----END PGP SIGNATURE-----")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGPGExportPublicKey(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, pubKey)
|
||||||
|
assert.Contains(t, string(pubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||||
|
assert.Contains(t, string(pubKey), "-----END PGP PUBLIC KEY BLOCK-----")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGPGGetKeyFingerprint(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
fingerprint, err := gpgGetKeyFingerprint(keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, fingerprint)
|
||||||
|
// The fingerprint should be 40 hex chars
|
||||||
|
assert.Len(t, fingerprint, 40, "fingerprint should be 40 hex chars")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGPGSignInvalidKey(t *testing.T) {
|
||||||
|
// Set up test environment (we need GNUPGHOME set)
|
||||||
|
_, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
data := []byte("test data")
|
||||||
|
_, err := gpgSign(data, GPGKeyID("NONEXISTENT_KEY_ID_12345"))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderWithSigning(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a builder with signing options
|
||||||
|
b := NewBuilder()
|
||||||
|
b.SetSigningOptions(&SigningOptions{
|
||||||
|
KeyID: keyID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add a test file
|
||||||
|
content := []byte("test file content")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Build the manifest
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the manifest and verify signature fields are populated
|
||||||
|
manifest, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, manifest.pbOuter)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, manifest.pbOuter.Signature, "signature should be populated")
|
||||||
|
assert.NotEmpty(t, manifest.pbOuter.Signer, "signer should be populated")
|
||||||
|
assert.NotEmpty(t, manifest.pbOuter.SigningPubKey, "signing public key should be populated")
|
||||||
|
|
||||||
|
// Verify signature is a valid PGP signature
|
||||||
|
assert.Contains(t, string(manifest.pbOuter.Signature), "-----BEGIN PGP SIGNATURE-----")
|
||||||
|
|
||||||
|
// Verify public key is a valid PGP public key block
|
||||||
|
assert.Contains(t, string(manifest.pbOuter.SigningPubKey), "-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerWithSigning(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create in-memory filesystem with test files
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content1"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content2"), 0o644))
|
||||||
|
|
||||||
|
// Create scanner with signing options
|
||||||
|
opts := &ScannerOptions{
|
||||||
|
Fs: fs,
|
||||||
|
SigningOptions: &SigningOptions{
|
||||||
|
KeyID: keyID,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s := NewScannerWithOptions(opts)
|
||||||
|
|
||||||
|
// Enumerate files
|
||||||
|
require.NoError(t, s.EnumeratePath("/testdir", nil))
|
||||||
|
assert.Equal(t, FileCount(2), s.FileCount())
|
||||||
|
|
||||||
|
// Generate signed manifest
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
|
||||||
|
|
||||||
|
// Parse and verify
|
||||||
|
manifest, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotEmpty(t, manifest.pbOuter.Signature)
|
||||||
|
assert.NotEmpty(t, manifest.pbOuter.Signer)
|
||||||
|
assert.NotEmpty(t, manifest.pbOuter.SigningPubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGPGVerify(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
data := []byte("test data to sign and verify")
|
||||||
|
sig, err := gpgSign(data, keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
err = gpgVerify(data, sig, pubKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGPGVerifyInvalidSignature(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
data := []byte("test data to sign")
|
||||||
|
sig, err := gpgSign(data, keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
pubKey, err := gpgExportPublicKey(keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to verify with different data - should fail
|
||||||
|
wrongData := []byte("different data")
|
||||||
|
err = gpgVerify(wrongData, sig, pubKey)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGPGVerifyBadPublicKey(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
data := []byte("test data")
|
||||||
|
sig, err := gpgSign(data, keyID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Try to verify with invalid public key - should fail
|
||||||
|
badPubKey := []byte("not a valid public key")
|
||||||
|
err = gpgVerify(data, sig, badPubKey)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestSignatureVerification(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a builder with signing options
|
||||||
|
b := NewBuilder()
|
||||||
|
b.SetSigningOptions(&SigningOptions{
|
||||||
|
KeyID: keyID,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add a test file
|
||||||
|
content := []byte("test file content for verification")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Build the manifest
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the manifest - signature should be verified during load
|
||||||
|
manifest, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, manifest)
|
||||||
|
|
||||||
|
// Signature should be present and valid
|
||||||
|
assert.NotEmpty(t, manifest.pbOuter.Signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestTamperedSignatureFails(t *testing.T) {
|
||||||
|
keyID, cleanup := testGPGEnv(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a signed manifest
|
||||||
|
b := NewBuilder()
|
||||||
|
b.SetSigningOptions(&SigningOptions{
|
||||||
|
KeyID: keyID,
|
||||||
|
})
|
||||||
|
|
||||||
|
content := []byte("test file content")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Tamper with the signature by replacing some bytes
|
||||||
|
data := buf.Bytes()
|
||||||
|
// Find and modify a byte in the signature portion
|
||||||
|
for i := range data {
|
||||||
|
if i > 100 && data[i] == 'A' {
|
||||||
|
data[i] = 'B'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to load the tampered manifest - should fail
|
||||||
|
_, err = NewManifestFromReader(bytes.NewReader(data))
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuilderWithoutSigning(t *testing.T) {
|
||||||
|
// Create a builder without signing options
|
||||||
|
b := NewBuilder()
|
||||||
|
|
||||||
|
// Add a test file
|
||||||
|
content := []byte("test file content")
|
||||||
|
reader := bytes.NewReader(content)
|
||||||
|
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Build the manifest
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = b.Build(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Parse the manifest and verify signature fields are empty
|
||||||
|
manifest, err := NewManifestFromReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, manifest.pbOuter)
|
||||||
|
|
||||||
|
assert.Empty(t, manifest.pbOuter.Signature, "signature should be empty when not signing")
|
||||||
|
assert.Empty(t, manifest.pbOuter.Signer, "signer should be empty when not signing")
|
||||||
|
assert.Empty(t, manifest.pbOuter.SigningPubKey, "signing public key should be empty when not signing")
|
||||||
|
}
|
||||||
60
mfer/manifest.go
Normal file
60
mfer/manifest.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/multiformats/go-multihash"
|
||||||
|
)
|
||||||
|
|
||||||
|
// manifest holds the internal representation of a manifest file.
|
||||||
|
// Use NewManifestFromFile or NewManifestFromReader to load an existing manifest,
|
||||||
|
// or use Builder to create a new one.
|
||||||
|
type manifest struct {
|
||||||
|
pbInner *MFFile
|
||||||
|
pbOuter *MFFileOuter
|
||||||
|
output *bytes.Buffer
|
||||||
|
signingOptions *SigningOptions
|
||||||
|
fixedUUID []byte // if set, use this UUID instead of generating one
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifest) String() string {
|
||||||
|
count := 0
|
||||||
|
if m.pbInner != nil {
|
||||||
|
count = len(m.pbInner.Files)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<Manifest count=%d>", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files returns all file entries from a loaded manifest.
|
||||||
|
func (m *manifest) Files() []*MFFilePath {
|
||||||
|
if m.pbInner == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.pbInner.Files
|
||||||
|
}
|
||||||
|
|
||||||
|
// signatureString generates the canonical string used for signing/verification.
|
||||||
|
// Format: MAGIC-UUID-MULTIHASH where UUID and multihash are hex-encoded.
|
||||||
|
// Requires pbOuter to be set with Uuid and Sha256 fields.
|
||||||
|
func (m *manifest) signatureString() (string, error) {
|
||||||
|
if m.pbOuter == nil {
|
||||||
|
return "", errors.New("pbOuter not set")
|
||||||
|
}
|
||||||
|
if len(m.pbOuter.Uuid) == 0 {
|
||||||
|
return "", errors.New("UUID not set")
|
||||||
|
}
|
||||||
|
if len(m.pbOuter.Sha256) == 0 {
|
||||||
|
return "", errors.New("SHA256 hash not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
mh, err := multihash.Encode(m.pbOuter.Sha256, multihash.SHA2_256)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to encode multihash: %w", err)
|
||||||
|
}
|
||||||
|
uuidStr := hex.EncodeToString(m.pbOuter.Uuid)
|
||||||
|
mhStr := hex.EncodeToString(mh)
|
||||||
|
return fmt.Sprintf("%s-%s-%s", MAGIC, uuidStr, mhStr), nil
|
||||||
|
}
|
||||||
3
mfer/mf.go
Normal file
3
mfer/mf.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
//go:generate protoc ./mf.proto --go_out=paths=source_relative:.
|
||||||
658
mfer/mf.pb.go
Normal file
658
mfer/mf.pb.go
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.11
|
||||||
|
// protoc v6.33.4
|
||||||
|
// source: mf.proto
|
||||||
|
|
||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type MFFileOuter_Version int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
MFFileOuter_VERSION_NONE MFFileOuter_Version = 0
|
||||||
|
MFFileOuter_VERSION_ONE MFFileOuter_Version = 1 // only one for now
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for MFFileOuter_Version.
|
||||||
|
var (
|
||||||
|
MFFileOuter_Version_name = map[int32]string{
|
||||||
|
0: "VERSION_NONE",
|
||||||
|
1: "VERSION_ONE",
|
||||||
|
}
|
||||||
|
MFFileOuter_Version_value = map[string]int32{
|
||||||
|
"VERSION_NONE": 0,
|
||||||
|
"VERSION_ONE": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x MFFileOuter_Version) Enum() *MFFileOuter_Version {
|
||||||
|
p := new(MFFileOuter_Version)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x MFFileOuter_Version) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MFFileOuter_Version) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_mf_proto_enumTypes[0].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MFFileOuter_Version) Type() protoreflect.EnumType {
|
||||||
|
return &file_mf_proto_enumTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x MFFileOuter_Version) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MFFileOuter_Version.Descriptor instead.
|
||||||
|
func (MFFileOuter_Version) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{1, 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MFFileOuter_CompressionType int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
MFFileOuter_COMPRESSION_NONE MFFileOuter_CompressionType = 0
|
||||||
|
MFFileOuter_COMPRESSION_ZSTD MFFileOuter_CompressionType = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for MFFileOuter_CompressionType.
|
||||||
|
var (
|
||||||
|
MFFileOuter_CompressionType_name = map[int32]string{
|
||||||
|
0: "COMPRESSION_NONE",
|
||||||
|
1: "COMPRESSION_ZSTD",
|
||||||
|
}
|
||||||
|
MFFileOuter_CompressionType_value = map[string]int32{
|
||||||
|
"COMPRESSION_NONE": 0,
|
||||||
|
"COMPRESSION_ZSTD": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x MFFileOuter_CompressionType) Enum() *MFFileOuter_CompressionType {
|
||||||
|
p := new(MFFileOuter_CompressionType)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x MFFileOuter_CompressionType) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MFFileOuter_CompressionType) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_mf_proto_enumTypes[1].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MFFileOuter_CompressionType) Type() protoreflect.EnumType {
|
||||||
|
return &file_mf_proto_enumTypes[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x MFFileOuter_CompressionType) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MFFileOuter_CompressionType.Descriptor instead.
|
||||||
|
func (MFFileOuter_CompressionType) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{1, 1}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MFFile_Version int32
|
||||||
|
|
||||||
|
const (
|
||||||
|
MFFile_VERSION_NONE MFFile_Version = 0
|
||||||
|
MFFile_VERSION_ONE MFFile_Version = 1 // only one for now
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enum value maps for MFFile_Version.
|
||||||
|
var (
|
||||||
|
MFFile_Version_name = map[int32]string{
|
||||||
|
0: "VERSION_NONE",
|
||||||
|
1: "VERSION_ONE",
|
||||||
|
}
|
||||||
|
MFFile_Version_value = map[string]int32{
|
||||||
|
"VERSION_NONE": 0,
|
||||||
|
"VERSION_ONE": 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (x MFFile_Version) Enum() *MFFile_Version {
|
||||||
|
p := new(MFFile_Version)
|
||||||
|
*p = x
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x MFFile_Version) String() string {
|
||||||
|
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MFFile_Version) Descriptor() protoreflect.EnumDescriptor {
|
||||||
|
return file_mf_proto_enumTypes[2].Descriptor()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (MFFile_Version) Type() protoreflect.EnumType {
|
||||||
|
return &file_mf_proto_enumTypes[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x MFFile_Version) Number() protoreflect.EnumNumber {
|
||||||
|
return protoreflect.EnumNumber(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MFFile_Version.Descriptor instead.
|
||||||
|
func (MFFile_Version) EnumDescriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{4, 0}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Timestamp struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Seconds int64 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"`
|
||||||
|
Nanos int32 `protobuf:"varint,2,opt,name=nanos,proto3" json:"nanos,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Timestamp) Reset() {
|
||||||
|
*x = Timestamp{}
|
||||||
|
mi := &file_mf_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Timestamp) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*Timestamp) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *Timestamp) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mf_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use Timestamp.ProtoReflect.Descriptor instead.
|
||||||
|
func (*Timestamp) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Timestamp) GetSeconds() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Seconds
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Timestamp) GetNanos() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Nanos
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type MFFileOuter struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// required mffile root attributes 1xx
|
||||||
|
Version MFFileOuter_Version `protobuf:"varint,101,opt,name=version,proto3,enum=MFFileOuter_Version" json:"version,omitempty"`
|
||||||
|
CompressionType MFFileOuter_CompressionType `protobuf:"varint,102,opt,name=compressionType,proto3,enum=MFFileOuter_CompressionType" json:"compressionType,omitempty"`
|
||||||
|
// these are used solely to detect corruption/truncation
|
||||||
|
// and not for cryptographic integrity.
|
||||||
|
Size int64 `protobuf:"varint,103,opt,name=size,proto3" json:"size,omitempty"`
|
||||||
|
Sha256 []byte `protobuf:"bytes,104,opt,name=sha256,proto3" json:"sha256,omitempty"`
|
||||||
|
// uuid must match the uuid in the inner message
|
||||||
|
Uuid []byte `protobuf:"bytes,105,opt,name=uuid,proto3" json:"uuid,omitempty"`
|
||||||
|
InnerMessage []byte `protobuf:"bytes,199,opt,name=innerMessage,proto3" json:"innerMessage,omitempty"`
|
||||||
|
// detached signature, ascii or binary
|
||||||
|
Signature []byte `protobuf:"bytes,201,opt,name=signature,proto3,oneof" json:"signature,omitempty"`
|
||||||
|
// full GPG key id
|
||||||
|
Signer []byte `protobuf:"bytes,202,opt,name=signer,proto3,oneof" json:"signer,omitempty"`
|
||||||
|
// full GPG signing public key, ascii or binary
|
||||||
|
SigningPubKey []byte `protobuf:"bytes,203,opt,name=signingPubKey,proto3,oneof" json:"signingPubKey,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) Reset() {
|
||||||
|
*x = MFFileOuter{}
|
||||||
|
mi := &file_mf_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MFFileOuter) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mf_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MFFileOuter.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MFFileOuter) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetVersion() MFFileOuter_Version {
|
||||||
|
if x != nil {
|
||||||
|
return x.Version
|
||||||
|
}
|
||||||
|
return MFFileOuter_VERSION_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetCompressionType() MFFileOuter_CompressionType {
|
||||||
|
if x != nil {
|
||||||
|
return x.CompressionType
|
||||||
|
}
|
||||||
|
return MFFileOuter_COMPRESSION_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetSize() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Size
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetSha256() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Sha256
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetUuid() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Uuid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetInnerMessage() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.InnerMessage
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetSignature() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Signature
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetSigner() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Signer
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileOuter) GetSigningPubKey() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.SigningPubKey
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MFFilePath struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// required attributes:
|
||||||
|
// Path invariants: must be valid UTF-8, use forward slashes only,
|
||||||
|
// be relative (no leading /), contain no ".." segments, and no
|
||||||
|
// empty segments (no "//").
|
||||||
|
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||||
|
Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"`
|
||||||
|
// gotta have at least one:
|
||||||
|
Hashes []*MFFileChecksum `protobuf:"bytes,3,rep,name=hashes,proto3" json:"hashes,omitempty"`
|
||||||
|
// optional per-file metadata
|
||||||
|
MimeType *string `protobuf:"bytes,301,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"`
|
||||||
|
Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"`
|
||||||
|
Ctime *Timestamp `protobuf:"bytes,303,opt,name=ctime,proto3,oneof" json:"ctime,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) Reset() {
|
||||||
|
*x = MFFilePath{}
|
||||||
|
mi := &file_mf_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MFFilePath) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MFFilePath) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mf_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MFFilePath.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MFFilePath) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) GetPath() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Path
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) GetSize() int64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Size
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) GetHashes() []*MFFileChecksum {
|
||||||
|
if x != nil {
|
||||||
|
return x.Hashes
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) GetMimeType() string {
|
||||||
|
if x != nil && x.MimeType != nil {
|
||||||
|
return *x.MimeType
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) GetMtime() *Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.Mtime
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFilePath) GetCtime() *Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.Ctime
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MFFileChecksum struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
// 1.0 golang implementation must write a multihash here
|
||||||
|
// it's ok to only ever use/verify sha256 multihash
|
||||||
|
MultiHash []byte `protobuf:"bytes,1,opt,name=multiHash,proto3" json:"multiHash,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileChecksum) Reset() {
|
||||||
|
*x = MFFileChecksum{}
|
||||||
|
mi := &file_mf_proto_msgTypes[3]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileChecksum) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MFFileChecksum) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MFFileChecksum) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mf_proto_msgTypes[3]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MFFileChecksum.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MFFileChecksum) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{3}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFileChecksum) GetMultiHash() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.MultiHash
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MFFile struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Version MFFile_Version `protobuf:"varint,100,opt,name=version,proto3,enum=MFFile_Version" json:"version,omitempty"`
|
||||||
|
// required manifest attributes:
|
||||||
|
Files []*MFFilePath `protobuf:"bytes,101,rep,name=files,proto3" json:"files,omitempty"`
|
||||||
|
// uuid is a random v4 UUID generated when creating the manifest
|
||||||
|
// used as part of the signature to prevent replay attacks
|
||||||
|
Uuid []byte `protobuf:"bytes,102,opt,name=uuid,proto3" json:"uuid,omitempty"`
|
||||||
|
// optional manifest attributes 2xx:
|
||||||
|
CreatedAt *Timestamp `protobuf:"bytes,201,opt,name=createdAt,proto3,oneof" json:"createdAt,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFile) Reset() {
|
||||||
|
*x = MFFile{}
|
||||||
|
mi := &file_mf_proto_msgTypes[4]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFile) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*MFFile) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *MFFile) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_mf_proto_msgTypes[4]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use MFFile.ProtoReflect.Descriptor instead.
|
||||||
|
func (*MFFile) Descriptor() ([]byte, []int) {
|
||||||
|
return file_mf_proto_rawDescGZIP(), []int{4}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFile) GetVersion() MFFile_Version {
|
||||||
|
if x != nil {
|
||||||
|
return x.Version
|
||||||
|
}
|
||||||
|
return MFFile_VERSION_NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFile) GetFiles() []*MFFilePath {
|
||||||
|
if x != nil {
|
||||||
|
return x.Files
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFile) GetUuid() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Uuid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *MFFile) GetCreatedAt() *Timestamp {
|
||||||
|
if x != nil {
|
||||||
|
return x.CreatedAt
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_mf_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
const file_mf_proto_rawDesc = "" +
|
||||||
|
"\n" +
|
||||||
|
"\bmf.proto\";\n" +
|
||||||
|
"\tTimestamp\x12\x18\n" +
|
||||||
|
"\aseconds\x18\x01 \x01(\x03R\aseconds\x12\x14\n" +
|
||||||
|
"\x05nanos\x18\x02 \x01(\x05R\x05nanos\"\xf0\x03\n" +
|
||||||
|
"\vMFFileOuter\x12.\n" +
|
||||||
|
"\aversion\x18e \x01(\x0e2\x14.MFFileOuter.VersionR\aversion\x12F\n" +
|
||||||
|
"\x0fcompressionType\x18f \x01(\x0e2\x1c.MFFileOuter.CompressionTypeR\x0fcompressionType\x12\x12\n" +
|
||||||
|
"\x04size\x18g \x01(\x03R\x04size\x12\x16\n" +
|
||||||
|
"\x06sha256\x18h \x01(\fR\x06sha256\x12\x12\n" +
|
||||||
|
"\x04uuid\x18i \x01(\fR\x04uuid\x12#\n" +
|
||||||
|
"\finnerMessage\x18\xc7\x01 \x01(\fR\finnerMessage\x12\"\n" +
|
||||||
|
"\tsignature\x18\xc9\x01 \x01(\fH\x00R\tsignature\x88\x01\x01\x12\x1c\n" +
|
||||||
|
"\x06signer\x18\xca\x01 \x01(\fH\x01R\x06signer\x88\x01\x01\x12*\n" +
|
||||||
|
"\rsigningPubKey\x18\xcb\x01 \x01(\fH\x02R\rsigningPubKey\x88\x01\x01\",\n" +
|
||||||
|
"\aVersion\x12\x10\n" +
|
||||||
|
"\fVERSION_NONE\x10\x00\x12\x0f\n" +
|
||||||
|
"\vVERSION_ONE\x10\x01\"=\n" +
|
||||||
|
"\x0fCompressionType\x12\x14\n" +
|
||||||
|
"\x10COMPRESSION_NONE\x10\x00\x12\x14\n" +
|
||||||
|
"\x10COMPRESSION_ZSTD\x10\x01B\f\n" +
|
||||||
|
"\n" +
|
||||||
|
"_signatureB\t\n" +
|
||||||
|
"\a_signerB\x10\n" +
|
||||||
|
"\x0e_signingPubKey\"\xf0\x01\n" +
|
||||||
|
"\n" +
|
||||||
|
"MFFilePath\x12\x12\n" +
|
||||||
|
"\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
|
||||||
|
"\x04size\x18\x02 \x01(\x03R\x04size\x12'\n" +
|
||||||
|
"\x06hashes\x18\x03 \x03(\v2\x0f.MFFileChecksumR\x06hashes\x12 \n" +
|
||||||
|
"\bmimeType\x18\xad\x02 \x01(\tH\x00R\bmimeType\x88\x01\x01\x12&\n" +
|
||||||
|
"\x05mtime\x18\xae\x02 \x01(\v2\n" +
|
||||||
|
".TimestampH\x01R\x05mtime\x88\x01\x01\x12&\n" +
|
||||||
|
"\x05ctime\x18\xaf\x02 \x01(\v2\n" +
|
||||||
|
".TimestampH\x02R\x05ctime\x88\x01\x01B\v\n" +
|
||||||
|
"\t_mimeTypeB\b\n" +
|
||||||
|
"\x06_mtimeB\b\n" +
|
||||||
|
"\x06_ctime\".\n" +
|
||||||
|
"\x0eMFFileChecksum\x12\x1c\n" +
|
||||||
|
"\tmultiHash\x18\x01 \x01(\fR\tmultiHash\"\xd6\x01\n" +
|
||||||
|
"\x06MFFile\x12)\n" +
|
||||||
|
"\aversion\x18d \x01(\x0e2\x0f.MFFile.VersionR\aversion\x12!\n" +
|
||||||
|
"\x05files\x18e \x03(\v2\v.MFFilePathR\x05files\x12\x12\n" +
|
||||||
|
"\x04uuid\x18f \x01(\fR\x04uuid\x12.\n" +
|
||||||
|
"\tcreatedAt\x18\xc9\x01 \x01(\v2\n" +
|
||||||
|
".TimestampH\x00R\tcreatedAt\x88\x01\x01\",\n" +
|
||||||
|
"\aVersion\x12\x10\n" +
|
||||||
|
"\fVERSION_NONE\x10\x00\x12\x0f\n" +
|
||||||
|
"\vVERSION_ONE\x10\x01B\f\n" +
|
||||||
|
"\n" +
|
||||||
|
"_createdAtB\x1dZ\x1bgit.eeqj.de/sneak/mfer/mferb\x06proto3"
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_mf_proto_rawDescOnce sync.Once
|
||||||
|
file_mf_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_mf_proto_rawDescGZIP() []byte {
|
||||||
|
file_mf_proto_rawDescOnce.Do(func() {
|
||||||
|
file_mf_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mf_proto_rawDesc), len(file_mf_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_mf_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_mf_proto_enumTypes = make([]protoimpl.EnumInfo, 3)
|
||||||
|
var file_mf_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
|
||||||
|
var file_mf_proto_goTypes = []any{
|
||||||
|
(MFFileOuter_Version)(0), // 0: MFFileOuter.Version
|
||||||
|
(MFFileOuter_CompressionType)(0), // 1: MFFileOuter.CompressionType
|
||||||
|
(MFFile_Version)(0), // 2: MFFile.Version
|
||||||
|
(*Timestamp)(nil), // 3: Timestamp
|
||||||
|
(*MFFileOuter)(nil), // 4: MFFileOuter
|
||||||
|
(*MFFilePath)(nil), // 5: MFFilePath
|
||||||
|
(*MFFileChecksum)(nil), // 6: MFFileChecksum
|
||||||
|
(*MFFile)(nil), // 7: MFFile
|
||||||
|
}
|
||||||
|
var file_mf_proto_depIdxs = []int32{
|
||||||
|
0, // 0: MFFileOuter.version:type_name -> MFFileOuter.Version
|
||||||
|
1, // 1: MFFileOuter.compressionType:type_name -> MFFileOuter.CompressionType
|
||||||
|
6, // 2: MFFilePath.hashes:type_name -> MFFileChecksum
|
||||||
|
3, // 3: MFFilePath.mtime:type_name -> Timestamp
|
||||||
|
3, // 4: MFFilePath.ctime:type_name -> Timestamp
|
||||||
|
2, // 5: MFFile.version:type_name -> MFFile.Version
|
||||||
|
5, // 6: MFFile.files:type_name -> MFFilePath
|
||||||
|
3, // 7: MFFile.createdAt:type_name -> Timestamp
|
||||||
|
8, // [8:8] is the sub-list for method output_type
|
||||||
|
8, // [8:8] is the sub-list for method input_type
|
||||||
|
8, // [8:8] is the sub-list for extension type_name
|
||||||
|
8, // [8:8] is the sub-list for extension extendee
|
||||||
|
0, // [0:8] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_mf_proto_init() }
|
||||||
|
func file_mf_proto_init() {
|
||||||
|
if File_mf_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file_mf_proto_msgTypes[1].OneofWrappers = []any{}
|
||||||
|
file_mf_proto_msgTypes[2].OneofWrappers = []any{}
|
||||||
|
file_mf_proto_msgTypes[4].OneofWrappers = []any{}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mf_proto_rawDesc), len(file_mf_proto_rawDesc)),
|
||||||
|
NumEnums: 3,
|
||||||
|
NumMessages: 5,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 0,
|
||||||
|
},
|
||||||
|
GoTypes: file_mf_proto_goTypes,
|
||||||
|
DependencyIndexes: file_mf_proto_depIdxs,
|
||||||
|
EnumInfos: file_mf_proto_enumTypes,
|
||||||
|
MessageInfos: file_mf_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_mf_proto = out.File
|
||||||
|
file_mf_proto_goTypes = nil
|
||||||
|
file_mf_proto_depIdxs = nil
|
||||||
|
}
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
|
|
||||||
option go_package = "git.eeqj.de/sneak/mfer";
|
option go_package = "git.eeqj.de/sneak/mfer/mfer";
|
||||||
|
|
||||||
message Timestamp {
|
message Timestamp {
|
||||||
int64 seconds = 1;
|
int64 seconds = 1;
|
||||||
int32 nanos = 2;
|
int32 nanos = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MFFile {
|
message MFFileOuter {
|
||||||
enum Version {
|
enum Version {
|
||||||
NONE = 0;
|
VERSION_NONE = 0;
|
||||||
ONE = 1; // only one for now
|
VERSION_ONE = 1; // only one for now
|
||||||
}
|
}
|
||||||
|
|
||||||
// required mffile root attributes 1xx
|
// required mffile root attributes 1xx
|
||||||
Version version = 101;
|
Version version = 101;
|
||||||
bytes innerMessage = 102;
|
|
||||||
|
enum CompressionType {
|
||||||
|
COMPRESSION_NONE = 0;
|
||||||
|
COMPRESSION_ZSTD = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompressionType compressionType = 102;
|
||||||
|
|
||||||
// these are used solely to detect corruption/truncation
|
// these are used solely to detect corruption/truncation
|
||||||
// and not for cryptographic integrity.
|
// and not for cryptographic integrity.
|
||||||
int64 size = 103;
|
int64 size = 103;
|
||||||
bytes sha256 = 104;
|
bytes sha256 = 104;
|
||||||
|
|
||||||
|
// uuid must match the uuid in the inner message
|
||||||
|
bytes uuid = 105;
|
||||||
|
|
||||||
|
bytes innerMessage = 199;
|
||||||
// 2xx for optional manifest root attributes
|
// 2xx for optional manifest root attributes
|
||||||
// think we might use gosignify instead of gpg:
|
// think we might use gosignify instead of gpg:
|
||||||
// github.com/frankbraun/gosignify
|
// github.com/frankbraun/gosignify
|
||||||
@@ -35,18 +46,19 @@ message MFFile {
|
|||||||
|
|
||||||
message MFFilePath {
|
message MFFilePath {
|
||||||
// required attributes:
|
// required attributes:
|
||||||
string path = 101;
|
// Path invariants: must be valid UTF-8, use forward slashes only,
|
||||||
int64 size = 102;
|
// be relative (no leading /), contain no ".." segments, and no
|
||||||
|
// empty segments (no "//").
|
||||||
|
string path = 1;
|
||||||
|
int64 size = 2;
|
||||||
|
|
||||||
// gotta have at least one:
|
// gotta have at least one:
|
||||||
repeated MFFileChecksum hashes = 201;
|
repeated MFFileChecksum hashes = 3;
|
||||||
|
|
||||||
// optional per-file metadata
|
// optional per-file metadata
|
||||||
optional string mimeType = 301;
|
optional string mimeType = 301;
|
||||||
optional Timestamp mtime = 302;
|
optional Timestamp mtime = 302;
|
||||||
optional Timestamp ctime = 303;
|
optional Timestamp ctime = 303;
|
||||||
optional Timestamp atime = 304;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message MFFileChecksum {
|
message MFFileChecksum {
|
||||||
@@ -55,16 +67,19 @@ message MFFileChecksum {
|
|||||||
bytes multiHash = 1;
|
bytes multiHash = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
message MFFileInner {
|
message MFFile {
|
||||||
enum Version {
|
enum Version {
|
||||||
NONE = 0;
|
VERSION_NONE = 0;
|
||||||
ONE = 1; // only one for now
|
VERSION_ONE = 1; // only one for now
|
||||||
}
|
}
|
||||||
Version version = 101;
|
Version version = 100;
|
||||||
|
|
||||||
// required manifest attributes:
|
// required manifest attributes:
|
||||||
int64 fileCount = 102; //FIXME is this necessary?
|
repeated MFFilePath files = 101;
|
||||||
repeated MFFilePath files = 103;
|
|
||||||
|
// uuid is a random v4 UUID generated when creating the manifest
|
||||||
|
// used as part of the signature to prevent replay attacks
|
||||||
|
bytes uuid = 102;
|
||||||
|
|
||||||
// optional manifest attributes 2xx:
|
// optional manifest attributes 2xx:
|
||||||
optional Timestamp createdAt = 201;
|
optional Timestamp createdAt = 201;
|
||||||
438
mfer/scanner.go
Normal file
438
mfer/scanner.go
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"sneak.berlin/go/mfer/internal/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 1: Enumeration
|
||||||
|
// ---------------------
|
||||||
|
// Walking directories and calling stat() on files to collect metadata.
|
||||||
|
// Builds the list of files to be scanned. Relatively fast (metadata only).
|
||||||
|
|
||||||
|
// EnumerateStatus contains progress information for the enumeration phase.
|
||||||
|
type EnumerateStatus struct {
|
||||||
|
FilesFound FileCount // Number of files discovered so far
|
||||||
|
BytesFound FileSize // Total size of discovered files (from stat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Scan (ToManifest)
|
||||||
|
// --------------------------
|
||||||
|
// Reading file contents and computing hashes for manifest generation.
|
||||||
|
// This is the expensive phase that reads all file data.
|
||||||
|
|
||||||
|
// ScanStatus contains progress information for the scan phase.
|
||||||
|
type ScanStatus struct {
|
||||||
|
TotalFiles FileCount // Total number of files to scan
|
||||||
|
ScannedFiles FileCount // Number of files scanned so far
|
||||||
|
TotalBytes FileSize // Total bytes to read (sum of all file sizes)
|
||||||
|
ScannedBytes FileSize // Bytes read so far
|
||||||
|
BytesPerSec float64 // Current throughput rate
|
||||||
|
ETA time.Duration // Estimated time to completion
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScannerOptions configures scanner behavior.
|
||||||
|
type ScannerOptions struct {
|
||||||
|
IncludeDotfiles bool // Include files and directories starting with a dot (default: exclude)
|
||||||
|
FollowSymLinks bool // Resolve symlinks instead of skipping them
|
||||||
|
IncludeTimestamps bool // Include createdAt timestamp in manifest (default: omit for determinism)
|
||||||
|
Fs afero.Fs // Filesystem to use, defaults to OsFs if nil
|
||||||
|
SigningOptions *SigningOptions // GPG signing options (nil = no signing)
|
||||||
|
Seed string // If set, derive a deterministic UUID from this seed
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileEntry represents a file that has been enumerated.
|
||||||
|
type FileEntry struct {
|
||||||
|
Path RelFilePath // Relative path (used in manifest)
|
||||||
|
AbsPath AbsFilePath // Absolute path (used for reading file content)
|
||||||
|
Size FileSize // File size in bytes
|
||||||
|
Mtime ModTime // Last modification time
|
||||||
|
Ctime time.Time // Creation time (platform-dependent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scanner accumulates files and generates manifests from them.
|
||||||
|
type Scanner struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
files []*FileEntry
|
||||||
|
totalBytes FileSize // cached sum of all file sizes
|
||||||
|
options *ScannerOptions
|
||||||
|
fs afero.Fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScanner creates a new Scanner with default options.
|
||||||
|
func NewScanner() *Scanner {
|
||||||
|
return NewScannerWithOptions(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScannerWithOptions creates a new Scanner with the given options.
|
||||||
|
func NewScannerWithOptions(opts *ScannerOptions) *Scanner {
|
||||||
|
if opts == nil {
|
||||||
|
opts = &ScannerOptions{}
|
||||||
|
}
|
||||||
|
fs := opts.Fs
|
||||||
|
if fs == nil {
|
||||||
|
fs = afero.NewOsFs()
|
||||||
|
}
|
||||||
|
return &Scanner{
|
||||||
|
files: make([]*FileEntry, 0),
|
||||||
|
options: opts,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnumerateFile adds a single file to the scanner, calling stat() to get metadata.
|
||||||
|
func (s *Scanner) EnumerateFile(filePath string) error {
|
||||||
|
abs, err := filepath.Abs(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := s.fs.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// For single files, use the filename as the relative path
|
||||||
|
basePath := filepath.Dir(abs)
|
||||||
|
return s.enumerateFileWithInfo(filepath.Base(abs), basePath, info, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnumeratePath walks a directory path and adds all files to the scanner.
|
||||||
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
|
// The progress channel is closed when the method returns.
|
||||||
|
func (s *Scanner) EnumeratePath(inputPath string, progress chan<- EnumerateStatus) error {
|
||||||
|
if progress != nil {
|
||||||
|
defer close(progress)
|
||||||
|
}
|
||||||
|
abs, err := filepath.Abs(inputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
||||||
|
return s.enumerateFS(afs, abs, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnumeratePaths walks multiple directory paths and adds all files to the scanner.
|
||||||
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
|
// The progress channel is closed when the method returns.
|
||||||
|
func (s *Scanner) EnumeratePaths(progress chan<- EnumerateStatus, inputPaths ...string) error {
|
||||||
|
if progress != nil {
|
||||||
|
defer close(progress)
|
||||||
|
}
|
||||||
|
for _, p := range inputPaths {
|
||||||
|
abs, err := filepath.Abs(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
afs := afero.NewReadOnlyFs(afero.NewBasePathFs(s.fs, abs))
|
||||||
|
if err := s.enumerateFS(afs, abs, progress); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnumerateFS walks an afero filesystem and adds all files to the scanner.
|
||||||
|
// If progress is non-nil, status updates are sent as files are discovered.
|
||||||
|
// The progress channel is closed when the method returns.
|
||||||
|
// basePath is used to compute absolute paths for file reading.
|
||||||
|
func (s *Scanner) EnumerateFS(afs afero.Fs, basePath string, progress chan<- EnumerateStatus) error {
|
||||||
|
if progress != nil {
|
||||||
|
defer close(progress)
|
||||||
|
}
|
||||||
|
return s.enumerateFS(afs, basePath, progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// enumerateFS is the internal implementation that doesn't close the progress channel.
|
||||||
|
func (s *Scanner) enumerateFS(afs afero.Fs, basePath string, progress chan<- EnumerateStatus) error {
|
||||||
|
return afero.Walk(afs, "/", func(p string, info fs.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !s.options.IncludeDotfiles && IsHiddenPath(p) {
|
||||||
|
if info.IsDir() {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.enumerateFileWithInfo(p, basePath, info, progress)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// enumerateFileWithInfo adds a file with pre-existing fs.FileInfo.
|
||||||
|
func (s *Scanner) enumerateFileWithInfo(filePath string, basePath string, info fs.FileInfo, progress chan<- EnumerateStatus) error {
|
||||||
|
if info.IsDir() {
|
||||||
|
// Manifests contain only files, directories are implied
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the path - remove leading slash if present
|
||||||
|
cleanPath := filePath
|
||||||
|
if len(cleanPath) > 0 && cleanPath[0] == '/' {
|
||||||
|
cleanPath = cleanPath[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute absolute path for file reading
|
||||||
|
absPath := filepath.Join(basePath, cleanPath)
|
||||||
|
|
||||||
|
// Handle symlinks
|
||||||
|
if info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
if !s.options.FollowSymLinks {
|
||||||
|
// Skip symlinks when not following them
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Resolve symlink to get real file info
|
||||||
|
realPath, err := filepath.EvalSymlinks(absPath)
|
||||||
|
if err != nil {
|
||||||
|
// Skip broken symlinks
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
realInfo, err := s.fs.Stat(realPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Skip if symlink points to a directory
|
||||||
|
if realInfo.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Use resolved path for reading, but keep original path in manifest
|
||||||
|
absPath = realPath
|
||||||
|
info = realInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &FileEntry{
|
||||||
|
Path: RelFilePath(cleanPath),
|
||||||
|
AbsPath: AbsFilePath(absPath),
|
||||||
|
Size: FileSize(info.Size()),
|
||||||
|
Mtime: ModTime(info.ModTime()),
|
||||||
|
// Note: Ctime not available from fs.FileInfo on all platforms
|
||||||
|
// Will need platform-specific code to extract it
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.files = append(s.files, entry)
|
||||||
|
s.totalBytes += entry.Size
|
||||||
|
filesFound := FileCount(len(s.files))
|
||||||
|
bytesFound := s.totalBytes
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
sendEnumerateStatus(progress, EnumerateStatus{
|
||||||
|
FilesFound: filesFound,
|
||||||
|
BytesFound: bytesFound,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files returns a copy of all files added to the scanner.
|
||||||
|
func (s *Scanner) Files() []*FileEntry {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
out := make([]*FileEntry, len(s.files))
|
||||||
|
copy(out, s.files)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileCount returns the number of files in the scanner.
|
||||||
|
func (s *Scanner) FileCount() FileCount {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return FileCount(len(s.files))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalBytes returns the total size of all files in the scanner.
|
||||||
|
func (s *Scanner) TotalBytes() FileSize {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.totalBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToManifest reads all file contents, computes hashes, and generates a manifest.
|
||||||
|
// If progress is non-nil, status updates are sent approximately once per second.
|
||||||
|
// The progress channel is closed when the method returns.
|
||||||
|
// The manifest is written to the provided io.Writer.
|
||||||
|
func (s *Scanner) ToManifest(ctx context.Context, w io.Writer, progress chan<- ScanStatus) error {
|
||||||
|
if progress != nil {
|
||||||
|
defer close(progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
files := make([]*FileEntry, len(s.files))
|
||||||
|
copy(files, s.files)
|
||||||
|
totalFiles := FileCount(len(files))
|
||||||
|
var totalBytes FileSize
|
||||||
|
for _, f := range files {
|
||||||
|
totalBytes += f.Size
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
builder := NewBuilder()
|
||||||
|
if s.options.IncludeTimestamps {
|
||||||
|
builder.SetIncludeTimestamps(true)
|
||||||
|
}
|
||||||
|
if s.options.SigningOptions != nil {
|
||||||
|
builder.SetSigningOptions(s.options.SigningOptions)
|
||||||
|
}
|
||||||
|
if s.options.Seed != "" {
|
||||||
|
builder.SetSeed(s.options.Seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
var scannedFiles FileCount
|
||||||
|
var scannedBytes FileSize
|
||||||
|
lastProgressTime := time.Now()
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
for _, entry := range files {
|
||||||
|
// Check for cancellation
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file
|
||||||
|
f, err := s.fs.Open(string(entry.AbsPath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create progress channel for this file
|
||||||
|
var fileProgress chan FileHashProgress
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
if progress != nil {
|
||||||
|
fileProgress = make(chan FileHashProgress, 1)
|
||||||
|
wg.Add(1)
|
||||||
|
go func(baseScannedBytes FileSize) {
|
||||||
|
defer wg.Done()
|
||||||
|
for p := range fileProgress {
|
||||||
|
// Send progress at most once per second
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(lastProgressTime) >= time.Second {
|
||||||
|
elapsed := now.Sub(startTime).Seconds()
|
||||||
|
currentBytes := baseScannedBytes + p.BytesRead
|
||||||
|
var rate float64
|
||||||
|
var eta time.Duration
|
||||||
|
if elapsed > 0 && currentBytes > 0 {
|
||||||
|
rate = float64(currentBytes) / elapsed
|
||||||
|
remainingBytes := totalBytes - currentBytes
|
||||||
|
if rate > 0 {
|
||||||
|
eta = time.Duration(float64(remainingBytes)/rate) * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendScanStatus(progress, ScanStatus{
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
ScannedFiles: scannedFiles,
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
ScannedBytes: currentBytes,
|
||||||
|
BytesPerSec: rate,
|
||||||
|
ETA: eta,
|
||||||
|
})
|
||||||
|
lastProgressTime = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(scannedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to manifest with progress channel
|
||||||
|
bytesRead, err := builder.AddFile(
|
||||||
|
entry.Path,
|
||||||
|
entry.Size,
|
||||||
|
entry.Mtime,
|
||||||
|
f,
|
||||||
|
fileProgress,
|
||||||
|
)
|
||||||
|
_ = f.Close()
|
||||||
|
|
||||||
|
// Close channel and wait for goroutine to finish
|
||||||
|
if fileProgress != nil {
|
||||||
|
close(fileProgress)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Verbosef("+ %s (%s)", entry.Path, humanize.IBytes(uint64(bytesRead)))
|
||||||
|
|
||||||
|
scannedFiles++
|
||||||
|
scannedBytes += bytesRead
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send final progress (ETA is 0 at completion)
|
||||||
|
if progress != nil {
|
||||||
|
elapsed := time.Since(startTime).Seconds()
|
||||||
|
var rate float64
|
||||||
|
if elapsed > 0 {
|
||||||
|
rate = float64(scannedBytes) / elapsed
|
||||||
|
}
|
||||||
|
sendScanStatus(progress, ScanStatus{
|
||||||
|
TotalFiles: totalFiles,
|
||||||
|
ScannedFiles: scannedFiles,
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
ScannedBytes: scannedBytes,
|
||||||
|
BytesPerSec: rate,
|
||||||
|
ETA: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and write manifest
|
||||||
|
return builder.Build(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsHiddenPath returns true if the path or any of its parent directories
|
||||||
|
// start with a dot (hidden files/directories).
|
||||||
|
// The path should use forward slashes.
|
||||||
|
func IsHiddenPath(p string) bool {
|
||||||
|
tp := path.Clean(p)
|
||||||
|
if tp == "." || tp == "/" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(tp, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
d, f := path.Split(tp)
|
||||||
|
if strings.HasPrefix(f, ".") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if d == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tp = d[0 : len(d)-1] // trim trailing slash from dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendEnumerateStatus sends a status update without blocking.
|
||||||
|
// If the channel is full, the update is dropped.
|
||||||
|
func sendEnumerateStatus(ch chan<- EnumerateStatus, status EnumerateStatus) {
|
||||||
|
if ch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- status:
|
||||||
|
default:
|
||||||
|
// Channel full, drop this update
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendScanStatus sends a status update without blocking.
|
||||||
|
// If the channel is full, the update is dropped.
|
||||||
|
func sendScanStatus(ch chan<- ScanStatus, status ScanStatus) {
|
||||||
|
if ch == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case ch <- status:
|
||||||
|
default:
|
||||||
|
// Channel full, drop this update
|
||||||
|
}
|
||||||
|
}
|
||||||
366
mfer/scanner_test.go
Normal file
366
mfer/scanner_test.go
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewScanner(t *testing.T) {
|
||||||
|
s := NewScanner()
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
assert.Equal(t, FileCount(0), s.FileCount())
|
||||||
|
assert.Equal(t, FileSize(0), s.TotalBytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewScannerWithOptions(t *testing.T) {
|
||||||
|
t.Run("nil options", func(t *testing.T) {
|
||||||
|
s := NewScannerWithOptions(nil)
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("with options", func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
opts := &ScannerOptions{
|
||||||
|
IncludeDotfiles: true,
|
||||||
|
FollowSymLinks: true,
|
||||||
|
Fs: fs,
|
||||||
|
}
|
||||||
|
s := NewScannerWithOptions(opts)
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerEnumerateFile(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello world"), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumerateFile("/test.txt")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(1), s.FileCount())
|
||||||
|
assert.Equal(t, FileSize(11), s.TotalBytes())
|
||||||
|
|
||||||
|
files := s.Files()
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
assert.Equal(t, RelFilePath("test.txt"), files[0].Path)
|
||||||
|
assert.Equal(t, FileSize(11), files[0].Size)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerEnumerateFileMissing(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumerateFile("/nonexistent.txt")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerEnumeratePath(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir/subdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/subdir/file3.txt", []byte("three"), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(3), s.FileCount())
|
||||||
|
assert.Equal(t, FileSize(3+3+5), s.TotalBytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerEnumeratePathWithProgress(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("one"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("two"), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
progress := make(chan EnumerateStatus, 10)
|
||||||
|
|
||||||
|
err := s.EnumeratePath("/testdir", progress)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var updates []EnumerateStatus
|
||||||
|
for p := range progress {
|
||||||
|
updates = append(updates, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEmpty(t, updates)
|
||||||
|
// Final update should show all files
|
||||||
|
final := updates[len(updates)-1]
|
||||||
|
assert.Equal(t, FileCount(2), final.FilesFound)
|
||||||
|
assert.Equal(t, FileSize(6), final.BytesFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerEnumeratePaths(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/dir1", 0o755))
|
||||||
|
require.NoError(t, fs.MkdirAll("/dir2", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/dir1/a.txt", []byte("aaa"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/dir2/b.txt", []byte("bbb"), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumeratePaths(nil, "/dir1", "/dir2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(2), s.FileCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerExcludeDotfiles(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir/.hidden", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/visible.txt", []byte("visible"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden.txt", []byte("hidden"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/.hidden/inside.txt", []byte("inside"), 0o644))
|
||||||
|
|
||||||
|
t.Run("exclude by default", func(t *testing.T) {
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: false})
|
||||||
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(1), s.FileCount())
|
||||||
|
files := s.Files()
|
||||||
|
assert.Equal(t, RelFilePath("visible.txt"), files[0].Path)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("include when enabled", func(t *testing.T) {
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs, IncludeDotfiles: true})
|
||||||
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(3), s.FileCount())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerToManifest(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file1.txt", []byte("content one"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file2.txt", []byte("content two"), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = s.ToManifest(context.Background(), &buf, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Manifest should have magic bytes
|
||||||
|
assert.True(t, buf.Len() > 0)
|
||||||
|
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerToManifestWithProgress(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", bytes.Repeat([]byte("x"), 1000), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
progress := make(chan ScanStatus, 10)
|
||||||
|
|
||||||
|
err = s.ToManifest(context.Background(), &buf, progress)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var updates []ScanStatus
|
||||||
|
for p := range progress {
|
||||||
|
updates = append(updates, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEmpty(t, updates)
|
||||||
|
// Final update should show completion
|
||||||
|
final := updates[len(updates)-1]
|
||||||
|
assert.Equal(t, FileCount(1), final.TotalFiles)
|
||||||
|
assert.Equal(t, FileCount(1), final.ScannedFiles)
|
||||||
|
assert.Equal(t, FileSize(1000), final.TotalBytes)
|
||||||
|
assert.Equal(t, FileSize(1000), final.ScannedBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerToManifestContextCancellation(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
// Create many files to ensure we have time to cancel
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
name := string(rune('a'+i%26)) + string(rune('0'+i/26)) + ".txt"
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/"+name, bytes.Repeat([]byte("x"), 100), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumeratePath("/testdir", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel() // Cancel immediately
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = s.ToManifest(ctx, &buf, nil)
|
||||||
|
assert.ErrorIs(t, err, context.Canceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerToManifestEmptyScanner(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err := s.ToManifest(context.Background(), &buf, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should still produce a valid manifest
|
||||||
|
assert.True(t, buf.Len() > 0)
|
||||||
|
assert.Equal(t, MAGIC, string(buf.Bytes()[:8]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerFilesCopiesSlice(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("hello"), 0o644))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
require.NoError(t, s.EnumerateFile("/test.txt"))
|
||||||
|
|
||||||
|
files1 := s.Files()
|
||||||
|
files2 := s.Files()
|
||||||
|
|
||||||
|
// Should be different slices
|
||||||
|
assert.NotSame(t, &files1[0], &files2[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerEnumerateFS(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir/sub", 0o755))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/file.txt", []byte("hello"), 0o644))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/testdir/sub/nested.txt", []byte("world"), 0o644))
|
||||||
|
|
||||||
|
// Create a basepath filesystem
|
||||||
|
baseFs := afero.NewBasePathFs(fs, "/testdir")
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
err := s.EnumerateFS(baseFs, "/testdir", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(2), s.FileCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEnumerateStatusNonBlocking(t *testing.T) {
|
||||||
|
// Channel with no buffer - send should not block
|
||||||
|
ch := make(chan EnumerateStatus)
|
||||||
|
|
||||||
|
// This should not block
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
sendEnumerateStatus(ch, EnumerateStatus{FilesFound: 1})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - did not block
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("sendEnumerateStatus blocked on full channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendScanStatusNonBlocking(t *testing.T) {
|
||||||
|
// Channel with no buffer - send should not block
|
||||||
|
ch := make(chan ScanStatus)
|
||||||
|
|
||||||
|
done := make(chan bool)
|
||||||
|
go func() {
|
||||||
|
sendScanStatus(ch, ScanStatus{ScannedFiles: 1})
|
||||||
|
done <- true
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// Success - did not block
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
t.Fatal("sendScanStatus blocked on full channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendStatusNilChannel(t *testing.T) {
|
||||||
|
// Should not panic with nil channel
|
||||||
|
sendEnumerateStatus(nil, EnumerateStatus{})
|
||||||
|
sendScanStatus(nil, ScanStatus{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerFileEntryFields(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
now := time.Now().Truncate(time.Second)
|
||||||
|
require.NoError(t, afero.WriteFile(fs, "/test.txt", []byte("content"), 0o644))
|
||||||
|
require.NoError(t, fs.Chtimes("/test.txt", now, now))
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
require.NoError(t, s.EnumerateFile("/test.txt"))
|
||||||
|
|
||||||
|
files := s.Files()
|
||||||
|
require.Len(t, files, 1)
|
||||||
|
|
||||||
|
entry := files[0]
|
||||||
|
assert.Equal(t, RelFilePath("test.txt"), entry.Path)
|
||||||
|
assert.Contains(t, string(entry.AbsPath), "test.txt")
|
||||||
|
assert.Equal(t, FileSize(7), entry.Size)
|
||||||
|
// Mtime should be set (within a second of now)
|
||||||
|
assert.WithinDuration(t, now, time.Time(entry.Mtime), 2*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScannerLargeFileEnumeration(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, fs.MkdirAll("/testdir", 0o755))
|
||||||
|
|
||||||
|
// Create 100 files
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
name := "/testdir/" + string(rune('a'+i%26)) + string(rune('0'+i/26%10)) + ".txt"
|
||||||
|
require.NoError(t, afero.WriteFile(fs, name, []byte("data"), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
s := NewScannerWithOptions(&ScannerOptions{Fs: fs})
|
||||||
|
progress := make(chan EnumerateStatus, 200)
|
||||||
|
|
||||||
|
err := s.EnumeratePath("/testdir", progress)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Drain channel
|
||||||
|
for range progress {
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, FileCount(100), s.FileCount())
|
||||||
|
assert.Equal(t, FileSize(400), s.TotalBytes()) // 100 * 4 bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHiddenPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
hidden bool
|
||||||
|
}{
|
||||||
|
{"file.txt", false},
|
||||||
|
{".hidden", true},
|
||||||
|
{"dir/file.txt", false},
|
||||||
|
{"dir/.hidden", true},
|
||||||
|
{".dir/file.txt", true},
|
||||||
|
{"/absolute/path", false},
|
||||||
|
{"/absolute/.hidden", true},
|
||||||
|
{"./relative", false}, // path.Clean removes leading ./
|
||||||
|
{"a/b/c/.d/e", true},
|
||||||
|
{".", false}, // current directory is not hidden (#14)
|
||||||
|
{"/", false}, // root is not hidden
|
||||||
|
{"./", false}, // current directory with trailing slash
|
||||||
|
{"./file.txt", false}, // file in current directory
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.path, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.hidden, IsHiddenPath(tt.path), "IsHiddenPath(%q)", tt.path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
123
mfer/serialize.go
Normal file
123
mfer/serialize.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/klauspost/compress/zstd"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MAGIC is the file format magic bytes prefix (rot13 of "MANIFEST").
|
||||||
|
const MAGIC string = "ZNAVSRFG"
|
||||||
|
|
||||||
|
func newTimestampFromTime(t time.Time) *Timestamp {
|
||||||
|
return &Timestamp{
|
||||||
|
Seconds: t.Unix(),
|
||||||
|
Nanos: int32(t.Nanosecond()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifest) generate() error {
|
||||||
|
if m.pbInner == nil {
|
||||||
|
return errors.New("internal error: pbInner not set")
|
||||||
|
}
|
||||||
|
if m.pbOuter == nil {
|
||||||
|
e := m.generateOuter()
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dat, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbOuter)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serialize: marshal outer: %w", err)
|
||||||
|
}
|
||||||
|
m.output = bytes.NewBuffer([]byte(MAGIC))
|
||||||
|
_, err = m.output.Write(dat)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serialize: write output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifest) generateOuter() error {
|
||||||
|
if m.pbInner == nil {
|
||||||
|
return errors.New("internal error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use fixed UUID if provided, otherwise generate a new one
|
||||||
|
var manifestUUID uuid.UUID
|
||||||
|
if len(m.fixedUUID) == 16 {
|
||||||
|
copy(manifestUUID[:], m.fixedUUID)
|
||||||
|
} else {
|
||||||
|
manifestUUID = uuid.New()
|
||||||
|
}
|
||||||
|
m.pbInner.Uuid = manifestUUID[:]
|
||||||
|
|
||||||
|
innerData, err := proto.MarshalOptions{Deterministic: true}.Marshal(m.pbInner)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serialize: marshal inner: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compress the inner data
|
||||||
|
idc := new(bytes.Buffer)
|
||||||
|
zw, err := zstd.NewWriter(idc, zstd.WithEncoderLevel(zstd.SpeedBestCompression))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serialize: create compressor: %w", err)
|
||||||
|
}
|
||||||
|
_, err = zw.Write(innerData)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("serialize: compress: %w", err)
|
||||||
|
}
|
||||||
|
_ = zw.Close()
|
||||||
|
|
||||||
|
compressedData := idc.Bytes()
|
||||||
|
|
||||||
|
// Hash the compressed data for integrity verification before decompression
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := h.Write(compressedData); err != nil {
|
||||||
|
return fmt.Errorf("serialize: hash write: %w", err)
|
||||||
|
}
|
||||||
|
sha256Hash := h.Sum(nil)
|
||||||
|
|
||||||
|
m.pbOuter = &MFFileOuter{
|
||||||
|
InnerMessage: compressedData,
|
||||||
|
Size: int64(len(innerData)),
|
||||||
|
Sha256: sha256Hash,
|
||||||
|
Uuid: manifestUUID[:],
|
||||||
|
Version: MFFileOuter_VERSION_ONE,
|
||||||
|
CompressionType: MFFileOuter_COMPRESSION_ZSTD,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sign the manifest if signing options are provided
|
||||||
|
if m.signingOptions != nil && m.signingOptions.KeyID != "" {
|
||||||
|
sigString, err := m.signatureString()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate signature string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sig, err := gpgSign([]byte(sigString), m.signingOptions.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to sign manifest: %w", err)
|
||||||
|
}
|
||||||
|
m.pbOuter.Signature = sig
|
||||||
|
|
||||||
|
fingerprint, err := gpgGetKeyFingerprint(m.signingOptions.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get key fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
m.pbOuter.Signer = fingerprint
|
||||||
|
|
||||||
|
pubKey, err := gpgExportPublicKey(m.signingOptions.KeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to export public key: %w", err)
|
||||||
|
}
|
||||||
|
m.pbOuter.SigningPubKey = pubKey
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
57
mfer/url.go
Normal file
57
mfer/url.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ManifestURL represents a URL pointing to a manifest file.
|
||||||
|
type ManifestURL string
|
||||||
|
|
||||||
|
// FileURL represents a URL pointing to a file to be fetched.
|
||||||
|
type FileURL string
|
||||||
|
|
||||||
|
// BaseURL represents a base URL for constructing file URLs.
|
||||||
|
type BaseURL string
|
||||||
|
|
||||||
|
// JoinPath safely joins a relative file path to a base URL.
|
||||||
|
// The path is properly URL-encoded to prevent path traversal.
|
||||||
|
func (b BaseURL) JoinPath(path RelFilePath) (FileURL, error) {
|
||||||
|
base, err := url.Parse(string(b))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure base path ends with /
|
||||||
|
if !strings.HasSuffix(base.Path, "/") {
|
||||||
|
base.Path += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode each path segment individually to preserve slashes
|
||||||
|
segments := strings.Split(string(path), "/")
|
||||||
|
for i, seg := range segments {
|
||||||
|
segments[i] = url.PathEscape(seg)
|
||||||
|
}
|
||||||
|
ref, err := url.Parse(strings.Join(segments, "/"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved := base.ResolveReference(ref)
|
||||||
|
return FileURL(resolved.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the URL as a string.
|
||||||
|
func (b BaseURL) String() string {
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the URL as a string.
|
||||||
|
func (f FileURL) String() string {
|
||||||
|
return string(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the URL as a string.
|
||||||
|
func (m ManifestURL) String() string {
|
||||||
|
return string(m)
|
||||||
|
}
|
||||||
44
mfer/url_test.go
Normal file
44
mfer/url_test.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package mfer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBaseURLJoinPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
base BaseURL
|
||||||
|
path RelFilePath
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"https://example.com/dir/", "file.txt", "https://example.com/dir/file.txt"},
|
||||||
|
{"https://example.com/dir", "file.txt", "https://example.com/dir/file.txt"},
|
||||||
|
{"https://example.com/", "sub/file.txt", "https://example.com/sub/file.txt"},
|
||||||
|
{"https://example.com/dir/", "file with spaces.txt", "https://example.com/dir/file%20with%20spaces.txt"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(string(tt.base)+"+"+string(tt.path), func(t *testing.T) {
|
||||||
|
result, err := tt.base.JoinPath(tt.path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, string(result))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBaseURLString(t *testing.T) {
|
||||||
|
b := BaseURL("https://example.com/")
|
||||||
|
assert.Equal(t, "https://example.com/", b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileURLString(t *testing.T) {
|
||||||
|
f := FileURL("https://example.com/file.txt")
|
||||||
|
assert.Equal(t, "https://example.com/file.txt", f.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManifestURLString(t *testing.T) {
|
||||||
|
m := ManifestURL("https://example.com/index.mf")
|
||||||
|
assert.Equal(t, "https://example.com/index.mf", m.String())
|
||||||
|
}
|
||||||
25
src/app.go
25
src/app.go
@@ -1,25 +0,0 @@
|
|||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
var NO_COLOR bool
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
NO_COLOR = false
|
|
||||||
if _, exists := os.LookupEnv("NO_COLOR"); exists {
|
|
||||||
NO_COLOR = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Run(Appname, Version, Buildarch string) int {
|
|
||||||
m := &mfer{}
|
|
||||||
m.appname = Appname
|
|
||||||
m.version = Version
|
|
||||||
m.buildarch = Buildarch
|
|
||||||
m.exitCode = 0
|
|
||||||
|
|
||||||
m.run()
|
|
||||||
return m.exitCode
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package mfer
|
|
||||||
|
|
||||||
//go:generate protoc --go_out=. --go_opt=paths=source_relative mf.proto
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MFGenerationJob struct {
|
|
||||||
sourcePath string
|
|
||||||
outputFile string
|
|
||||||
innerpb *MFFileInner
|
|
||||||
outerpb *MFFile
|
|
||||||
fileCount int64
|
|
||||||
totalSize int64
|
|
||||||
afs afero.Fs
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMFGenerationJobFromFilesystem(sourcePath string) (*MFGenerationJob, error) {
|
|
||||||
afs := afero.NewOsFs()
|
|
||||||
exists, err := afero.DirExists(afs, sourcePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
return nil, fmt.Errorf("source directory does not exist")
|
|
||||||
}
|
|
||||||
|
|
||||||
mgj := MFGenerationJob{}
|
|
||||||
mgj.afs = afs
|
|
||||||
mgj.sourcePath = sourcePath
|
|
||||||
|
|
||||||
return &mgj, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *MFGenerationJob) scanForFiles() error {
|
|
||||||
|
|
||||||
m.innerpb = &MFFileInner{}
|
|
||||||
m.innerpb.Version = MFFileInner_ONE
|
|
||||||
|
|
||||||
walkErr := filepath.Walk(m.sourcePath, func(itemPath string, info os.FileInfo, err error) error {
|
|
||||||
|
|
||||||
// we do not include the manifest file in the manifest
|
|
||||||
if itemPath == "index.mf" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fpi := MFFilePath{}
|
|
||||||
fpi.Path = itemPath
|
|
||||||
fpi.Size = info.Size()
|
|
||||||
m.innerpb.Files = append(m.innerpb.Files, &fpi)
|
|
||||||
m.fileCount++
|
|
||||||
m.totalSize += fpi.Size
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if walkErr != nil {
|
|
||||||
log.Fatal(walkErr)
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("%#v\n", m.innerpb)
|
|
||||||
fmt.Printf("filecount = %#v\n", m.fileCount)
|
|
||||||
fmt.Printf("totalsize = %#v\n", m.totalSize)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
110
src/mfer.go
110
src/mfer.go
@@ -1,110 +0,0 @@
|
|||||||
package mfer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/pterm/pterm"
|
|
||||||
"github.com/urfave/cli/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mfer struct {
|
|
||||||
appname string
|
|
||||||
version string
|
|
||||||
buildarch string
|
|
||||||
startupTime time.Time
|
|
||||||
exitCode int
|
|
||||||
errorString string
|
|
||||||
app *cli.App
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mfer) printBanner() {
|
|
||||||
s, _ := pterm.DefaultBigText.WithLetters(pterm.NewLettersFromString(m.appname)).Srender()
|
|
||||||
pterm.DefaultCenter.Println(s) // Print BigLetters with the default CenterPrinter
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mfer) disableStyling() {
|
|
||||||
pterm.DisableColor()
|
|
||||||
pterm.DisableStyling()
|
|
||||||
pterm.Debug.Prefix.Text = ""
|
|
||||||
pterm.Info.Prefix.Text = ""
|
|
||||||
pterm.Success.Prefix.Text = ""
|
|
||||||
pterm.Warning.Prefix.Text = ""
|
|
||||||
pterm.Error.Prefix.Text = ""
|
|
||||||
pterm.Fatal.Prefix.Text = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mfer) run() {
|
|
||||||
|
|
||||||
if NO_COLOR {
|
|
||||||
// shoutout to rob pike who thinks it's juvenile
|
|
||||||
m.disableStyling()
|
|
||||||
}
|
|
||||||
|
|
||||||
m.printBanner()
|
|
||||||
|
|
||||||
m.app = &cli.App{
|
|
||||||
Name: m.appname,
|
|
||||||
Usage: "Manifest generator",
|
|
||||||
Version: m.version,
|
|
||||||
EnableBashCompletion: true,
|
|
||||||
Commands: []*cli.Command{
|
|
||||||
{
|
|
||||||
Name: "generate",
|
|
||||||
Aliases: []string{"gen"},
|
|
||||||
Usage: "Generate manifest file",
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
return m.generateManifestOperation(c)
|
|
||||||
},
|
|
||||||
Flags: []cli.Flag{
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "input",
|
|
||||||
Value: ".",
|
|
||||||
Aliases: []string{"i"},
|
|
||||||
Usage: "Specify input directory.",
|
|
||||||
},
|
|
||||||
&cli.StringFlag{
|
|
||||||
Name: "output",
|
|
||||||
Value: "./index.mf",
|
|
||||||
Aliases: []string{"o"},
|
|
||||||
Usage: "Specify output filename",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Name: "check",
|
|
||||||
Usage: "Validate files using manifest file",
|
|
||||||
Action: func(c *cli.Context) error {
|
|
||||||
return m.validateManifestOperation(c)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := m.app.Run(os.Args)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
m.exitCode = 1
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mfer) validateManifestOperation(c *cli.Context) error {
|
|
||||||
log.Fatal("unimplemented")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mfer) generateManifestOperation(c *cli.Context) error {
|
|
||||||
fmt.Println("generateManifest()")
|
|
||||||
mgj, err := NewMFGenerationJobFromFilesystem(c.String("input"))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
mgj.scanForFiles()
|
|
||||||
//mgj.outputFile = c.String("output")
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
31
vendor/github.com/atomicgo/cursor/.gitignore
generated
vendored
31
vendor/github.com/atomicgo/cursor/.gitignore
generated
vendored
@@ -1,31 +0,0 @@
|
|||||||
### Go template
|
|
||||||
# Binaries for programs and plugins
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
vendor/
|
|
||||||
|
|
||||||
### IntelliJ
|
|
||||||
.idea
|
|
||||||
*.iml
|
|
||||||
out
|
|
||||||
gen
|
|
||||||
|
|
||||||
### VisualStudioCode
|
|
||||||
.vscode
|
|
||||||
*.code-workspace
|
|
||||||
|
|
||||||
### macOS
|
|
||||||
# General
|
|
||||||
.DS_Store
|
|
||||||
experimenting
|
|
||||||
71
vendor/github.com/atomicgo/cursor/.golangci.yml
generated
vendored
71
vendor/github.com/atomicgo/cursor/.golangci.yml
generated
vendored
@@ -1,71 +0,0 @@
|
|||||||
linters-settings:
|
|
||||||
gocritic:
|
|
||||||
enabled-tags:
|
|
||||||
- diagnostic
|
|
||||||
- experimental
|
|
||||||
- opinionated
|
|
||||||
- performance
|
|
||||||
- style
|
|
||||||
disabled-checks:
|
|
||||||
- dupImport
|
|
||||||
- ifElseChain
|
|
||||||
- octalLiteral
|
|
||||||
- whyNoLint
|
|
||||||
- wrapperFunc
|
|
||||||
- exitAfterDefer
|
|
||||||
- hugeParam
|
|
||||||
- ptrToRefParam
|
|
||||||
- paramTypeCombine
|
|
||||||
- unnamedResult
|
|
||||||
misspell:
|
|
||||||
locale: US
|
|
||||||
linters:
|
|
||||||
disable-all: true
|
|
||||||
enable:
|
|
||||||
- errcheck
|
|
||||||
- gosimple
|
|
||||||
- govet
|
|
||||||
- ineffassign
|
|
||||||
- staticcheck
|
|
||||||
- asciicheck
|
|
||||||
- bodyclose
|
|
||||||
- dupl
|
|
||||||
- durationcheck
|
|
||||||
- errorlint
|
|
||||||
- exhaustive
|
|
||||||
- gci
|
|
||||||
- gocognit
|
|
||||||
- gocritic
|
|
||||||
- godot
|
|
||||||
- godox
|
|
||||||
- goerr113
|
|
||||||
- gofmt
|
|
||||||
- goimports
|
|
||||||
- goprintffuncname
|
|
||||||
- misspell
|
|
||||||
- nilerr
|
|
||||||
- nlreturn
|
|
||||||
- noctx
|
|
||||||
- prealloc
|
|
||||||
- predeclared
|
|
||||||
- thelper
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
- wastedassign
|
|
||||||
- wrapcheck
|
|
||||||
issues:
|
|
||||||
# Excluding configuration per-path, per-linter, per-text and per-source
|
|
||||||
exclude-rules:
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- errcheck
|
|
||||||
- dupl
|
|
||||||
- gocritic
|
|
||||||
- wrapcheck
|
|
||||||
- goerr113
|
|
||||||
# https://github.com/go-critic/go-critic/issues/926
|
|
||||||
- linters:
|
|
||||||
- gocritic
|
|
||||||
text: "unnecessaryDefer:"
|
|
||||||
service:
|
|
||||||
golangci-lint-version: 1.39.x # use the fixed version to not introduce new linters unexpectedly
|
|
||||||
0
vendor/github.com/atomicgo/cursor/CHANGELOG.md
generated
vendored
0
vendor/github.com/atomicgo/cursor/CHANGELOG.md
generated
vendored
21
vendor/github.com/atomicgo/cursor/LICENSE
generated
vendored
21
vendor/github.com/atomicgo/cursor/LICENSE
generated
vendored
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2020 Marvin Wendt (MarvinJWendt)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
237
vendor/github.com/atomicgo/cursor/README.md
generated
vendored
237
vendor/github.com/atomicgo/cursor/README.md
generated
vendored
@@ -1,237 +0,0 @@
|
|||||||
<h1 align="center">AtomicGo | cursor</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
|
|
||||||
<a href="https://github.com/atomicgo/cursor/releases">
|
|
||||||
<img src="https://img.shields.io/github/v/release/atomicgo/cursor?style=flat-square" alt="Latest Release">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://codecov.io/gh/atomicgo/cursor" target="_blank">
|
|
||||||
<img src="https://img.shields.io/github/workflow/status/atomicgo/cursor/Go?label=tests&style=flat-square" alt="Tests">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://codecov.io/gh/atomicgo/cursor" target="_blank">
|
|
||||||
<img src="https://img.shields.io/codecov/c/gh/atomicgo/cursor?color=magenta&logo=codecov&style=flat-square" alt="Coverage">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://codecov.io/gh/atomicgo/cursor">
|
|
||||||
<!-- unittestcount:start --><img src="https://img.shields.io/badge/Unit_Tests-2-magenta?style=flat-square" alt="Unit test count"><!-- unittestcount:end -->
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://github.com/atomicgo/cursor/issues">
|
|
||||||
<img src="https://img.shields.io/github/issues/atomicgo/cursor.svg?style=flat-square" alt="Issues">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="https://opensource.org/licenses/MIT" target="_blank">
|
|
||||||
<img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square" alt="License: MIT">
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<strong><a href="#install">Get The Module</a></strong>
|
|
||||||
|
|
|
||||||
<strong><a href="https://pkg.go.dev/github.com/atomicgo/cursor#section-documentation" target="_blank">Documentation</a></strong>
|
|
||||||
|
|
|
||||||
<strong><a href="https://github.com/atomicgo/atomicgo/blob/main/CONTRIBUTING.md" target="_blank">Contributing</a></strong>
|
|
||||||
|
|
|
||||||
<strong><a href="https://github.com/atomicgo/atomicgo/blob/main/CODE_OF_CONDUCT.md" target="_blank">Code of Conduct</a></strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<img src="https://raw.githubusercontent.com/atomicgo/atomicgo/main/assets/header.png" alt="AtomicGo">
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
Package cursor contains cross-platform methods to move the terminal cursor in
|
|
||||||
different directions. This package can be used to create interactive CLI tools
|
|
||||||
and games, live charts, algorithm visualizations and other updatable output of
|
|
||||||
any kind.
|
|
||||||
|
|
||||||
Special thanks to github.com/k0kubun/go-ansi which this project is based on.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```console
|
|
||||||
# Execute this command inside your project
|
|
||||||
go get -u github.com/atomicgo/cursor
|
|
||||||
```
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Add this to your imports
|
|
||||||
import "github.com/atomicgo/cursor"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
#### func Bottom
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Bottom()
|
|
||||||
```
|
|
||||||
Bottom moves the cursor to the bottom of the terminal. This is done by
|
|
||||||
calculating how many lines were moved by Up and Down.
|
|
||||||
|
|
||||||
#### func ClearLine
|
|
||||||
|
|
||||||
```go
|
|
||||||
func ClearLine()
|
|
||||||
```
|
|
||||||
ClearLine clears the current line and moves the cursor to it's start position.
|
|
||||||
|
|
||||||
#### func ClearLinesDown
|
|
||||||
|
|
||||||
```go
|
|
||||||
func ClearLinesDown(n int)
|
|
||||||
```
|
|
||||||
ClearLinesDown clears n lines downwards from the current position and moves the
|
|
||||||
cursor.
|
|
||||||
|
|
||||||
#### func ClearLinesUp
|
|
||||||
|
|
||||||
```go
|
|
||||||
func ClearLinesUp(n int)
|
|
||||||
```
|
|
||||||
ClearLinesUp clears n lines upwards from the current position and moves the
|
|
||||||
cursor.
|
|
||||||
|
|
||||||
#### func Down
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Down(n int)
|
|
||||||
```
|
|
||||||
Down moves the cursor n lines down relative to the current position.
|
|
||||||
|
|
||||||
#### func DownAndClear
|
|
||||||
|
|
||||||
```go
|
|
||||||
func DownAndClear(n int)
|
|
||||||
```
|
|
||||||
DownAndClear moves the cursor down by n lines, then clears the line.
|
|
||||||
|
|
||||||
#### func Hide
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Hide()
|
|
||||||
```
|
|
||||||
Hide the cursor. Don't forget to show the cursor at least at the end of your
|
|
||||||
application with Show. Otherwise the user might have a terminal with a
|
|
||||||
permanently hidden cursor, until he reopens the terminal.
|
|
||||||
|
|
||||||
#### func HorizontalAbsolute
|
|
||||||
|
|
||||||
```go
|
|
||||||
func HorizontalAbsolute(n int)
|
|
||||||
```
|
|
||||||
HorizontalAbsolute moves the cursor to n horizontally. The position n is
|
|
||||||
absolute to the start of the line.
|
|
||||||
|
|
||||||
#### func Left
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Left(n int)
|
|
||||||
```
|
|
||||||
Left moves the cursor n characters to the left relative to the current position.
|
|
||||||
|
|
||||||
#### func Move
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Move(x, y int)
|
|
||||||
```
|
|
||||||
Move moves the cursor relative by x and y.
|
|
||||||
|
|
||||||
#### func Right
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Right(n int)
|
|
||||||
```
|
|
||||||
Right moves the cursor n characters to the right relative to the current
|
|
||||||
position.
|
|
||||||
|
|
||||||
#### func Show
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Show()
|
|
||||||
```
|
|
||||||
Show the cursor if it was hidden previously. Don't forget to show the cursor at
|
|
||||||
least at the end of your application. Otherwise the user might have a terminal
|
|
||||||
with a permanently hidden cursor, until he reopens the terminal.
|
|
||||||
|
|
||||||
#### func StartOfLine
|
|
||||||
|
|
||||||
```go
|
|
||||||
func StartOfLine()
|
|
||||||
```
|
|
||||||
StartOfLine moves the cursor to the start of the current line.
|
|
||||||
|
|
||||||
#### func StartOfLineDown
|
|
||||||
|
|
||||||
```go
|
|
||||||
func StartOfLineDown(n int)
|
|
||||||
```
|
|
||||||
StartOfLineDown moves the cursor down by n lines, then moves to cursor to the
|
|
||||||
start of the line.
|
|
||||||
|
|
||||||
#### func StartOfLineUp
|
|
||||||
|
|
||||||
```go
|
|
||||||
func StartOfLineUp(n int)
|
|
||||||
```
|
|
||||||
StartOfLineUp moves the cursor up by n lines, then moves to cursor to the start
|
|
||||||
of the line.
|
|
||||||
|
|
||||||
#### func Up
|
|
||||||
|
|
||||||
```go
|
|
||||||
func Up(n int)
|
|
||||||
```
|
|
||||||
Up moves the cursor n lines up relative to the current position.
|
|
||||||
|
|
||||||
#### func UpAndClear
|
|
||||||
|
|
||||||
```go
|
|
||||||
func UpAndClear(n int)
|
|
||||||
```
|
|
||||||
UpAndClear moves the cursor up by n lines, then clears the line.
|
|
||||||
|
|
||||||
#### type Area
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Area struct {
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Area displays content which can be updated on the fly. You can use this to
|
|
||||||
create live output, charts, dropdowns, etc.
|
|
||||||
|
|
||||||
#### func NewArea
|
|
||||||
|
|
||||||
```go
|
|
||||||
func NewArea() Area
|
|
||||||
```
|
|
||||||
NewArea returns a new Area.
|
|
||||||
|
|
||||||
#### func (*Area) Clear
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (area *Area) Clear()
|
|
||||||
```
|
|
||||||
Clear clears the content of the Area.
|
|
||||||
|
|
||||||
#### func (*Area) Update
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (area *Area) Update(content string)
|
|
||||||
```
|
|
||||||
Update overwrites the content of the Area.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
> [AtomicGo.dev](https://atomicgo.dev) ·
|
|
||||||
> with ❤️ by [@MarvinJWendt](https://github.com/MarvinJWendt) |
|
|
||||||
> [MarvinJWendt.com](https://marvinjwendt.com)
|
|
||||||
45
vendor/github.com/atomicgo/cursor/area.go
generated
vendored
45
vendor/github.com/atomicgo/cursor/area.go
generated
vendored
@@ -1,45 +0,0 @@
|
|||||||
package cursor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Area displays content which can be updated on the fly.
|
|
||||||
// You can use this to create live output, charts, dropdowns, etc.
|
|
||||||
type Area struct {
|
|
||||||
height int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewArea returns a new Area.
|
|
||||||
func NewArea() Area {
|
|
||||||
return Area{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear clears the content of the Area.
|
|
||||||
func (area *Area) Clear() {
|
|
||||||
Bottom()
|
|
||||||
if area.height > 0 {
|
|
||||||
ClearLinesUp(area.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update overwrites the content of the Area.
|
|
||||||
func (area *Area) Update(content string) {
|
|
||||||
area.Clear()
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
for _, line := range lines {
|
|
||||||
fmt.Print(line)
|
|
||||||
StartOfLineDown(1)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, line := range lines {
|
|
||||||
fmt.Println(line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
height = 0
|
|
||||||
|
|
||||||
area.height = len(lines)
|
|
||||||
}
|
|
||||||
59
vendor/github.com/atomicgo/cursor/cursor.go
generated
vendored
59
vendor/github.com/atomicgo/cursor/cursor.go
generated
vendored
@@ -1,59 +0,0 @@
|
|||||||
// +build !windows
|
|
||||||
|
|
||||||
package cursor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Up moves the cursor n lines up relative to the current position.
|
|
||||||
func Up(n int) {
|
|
||||||
fmt.Printf("\x1b[%dA", n)
|
|
||||||
height += n
|
|
||||||
}
|
|
||||||
|
|
||||||
// Down moves the cursor n lines down relative to the current position.
|
|
||||||
func Down(n int) {
|
|
||||||
fmt.Printf("\x1b[%dB", n)
|
|
||||||
if height-n <= 0 {
|
|
||||||
height = 0
|
|
||||||
} else {
|
|
||||||
height -= n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right moves the cursor n characters to the right relative to the current position.
|
|
||||||
func Right(n int) {
|
|
||||||
fmt.Printf("\x1b[%dC", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left moves the cursor n characters to the left relative to the current position.
|
|
||||||
func Left(n int) {
|
|
||||||
fmt.Printf("\x1b[%dD", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HorizontalAbsolute moves the cursor to n horizontally.
|
|
||||||
// The position n is absolute to the start of the line.
|
|
||||||
func HorizontalAbsolute(n int) {
|
|
||||||
n += 1 // Moves the line to the character after n
|
|
||||||
fmt.Printf("\x1b[%dG", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the cursor if it was hidden previously.
|
|
||||||
// Don't forget to show the cursor at least at the end of your application.
|
|
||||||
// Otherwise the user might have a terminal with a permanently hidden cursor, until he reopens the terminal.
|
|
||||||
func Show() {
|
|
||||||
fmt.Print("\x1b[?25h")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the cursor.
|
|
||||||
// Don't forget to show the cursor at least at the end of your application with Show.
|
|
||||||
// Otherwise the user might have a terminal with a permanently hidden cursor, until he reopens the terminal.
|
|
||||||
func Hide() {
|
|
||||||
fmt.Print("\x1b[?25l")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearLine clears the current line and moves the cursor to it's start position.
|
|
||||||
func ClearLine() {
|
|
||||||
fmt.Print("\x1b[2K")
|
|
||||||
}
|
|
||||||
105
vendor/github.com/atomicgo/cursor/cursor_windows.go
generated
vendored
105
vendor/github.com/atomicgo/cursor/cursor_windows.go
generated
vendored
@@ -1,105 +0,0 @@
|
|||||||
package cursor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"syscall"
|
|
||||||
"unsafe"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Up moves the cursor n lines up relative to the current position.
|
|
||||||
func Up(n int) {
|
|
||||||
move(0, -n)
|
|
||||||
height += n
|
|
||||||
}
|
|
||||||
|
|
||||||
// Down moves the cursor n lines down relative to the current position.
|
|
||||||
func Down(n int) {
|
|
||||||
move(0, n)
|
|
||||||
if height-n <= 0 {
|
|
||||||
height = 0
|
|
||||||
} else {
|
|
||||||
height -= n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right moves the cursor n characters to the right relative to the current position.
|
|
||||||
func Right(n int) {
|
|
||||||
move(n, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left moves the cursor n characters to the left relative to the current position.
|
|
||||||
func Left(n int) {
|
|
||||||
move(-n, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func move(x int, y int) {
|
|
||||||
handle := syscall.Handle(os.Stdout.Fd())
|
|
||||||
|
|
||||||
var csbi consoleScreenBufferInfo
|
|
||||||
_, _, _ = procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi)))
|
|
||||||
|
|
||||||
var cursor coord
|
|
||||||
cursor.x = csbi.cursorPosition.x + short(x)
|
|
||||||
cursor.y = csbi.cursorPosition.y + short(y)
|
|
||||||
|
|
||||||
_, _, _ = procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor))))
|
|
||||||
}
|
|
||||||
|
|
||||||
// HorizontalAbsolute moves the cursor to n horizontally.
|
|
||||||
// The position n is absolute to the start of the line.
|
|
||||||
func HorizontalAbsolute(n int) {
|
|
||||||
handle := syscall.Handle(os.Stdout.Fd())
|
|
||||||
|
|
||||||
var csbi consoleScreenBufferInfo
|
|
||||||
_, _, _ = procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi)))
|
|
||||||
|
|
||||||
var cursor coord
|
|
||||||
cursor.x = short(n)
|
|
||||||
cursor.y = csbi.cursorPosition.y
|
|
||||||
|
|
||||||
if csbi.size.x < cursor.x {
|
|
||||||
cursor.x = csbi.size.x
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _, _ = procSetConsoleCursorPosition.Call(uintptr(handle), uintptr(*(*int32)(unsafe.Pointer(&cursor))))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the cursor if it was hidden previously.
|
|
||||||
// Don't forget to show the cursor at least at the end of your application.
|
|
||||||
// Otherwise the user might have a terminal with a permanently hidden cursor, until he reopens the terminal.
|
|
||||||
func Show() {
|
|
||||||
handle := syscall.Handle(os.Stdout.Fd())
|
|
||||||
|
|
||||||
var cci consoleCursorInfo
|
|
||||||
_, _, _ = procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci)))
|
|
||||||
cci.visible = 1
|
|
||||||
|
|
||||||
_, _, _ = procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide the cursor.
|
|
||||||
// Don't forget to show the cursor at least at the end of your application with Show.
|
|
||||||
// Otherwise the user might have a terminal with a permanently hidden cursor, until he reopens the terminal.
|
|
||||||
func Hide() {
|
|
||||||
handle := syscall.Handle(os.Stdout.Fd())
|
|
||||||
|
|
||||||
var cci consoleCursorInfo
|
|
||||||
_, _, _ = procGetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci)))
|
|
||||||
cci.visible = 0
|
|
||||||
|
|
||||||
_, _, _ = procSetConsoleCursorInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&cci)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearLine clears the current line and moves the cursor to it's start position.
|
|
||||||
func ClearLine() {
|
|
||||||
handle := syscall.Handle(os.Stdout.Fd())
|
|
||||||
|
|
||||||
var csbi consoleScreenBufferInfo
|
|
||||||
_, _, _ = procGetConsoleScreenBufferInfo.Call(uintptr(handle), uintptr(unsafe.Pointer(&csbi)))
|
|
||||||
|
|
||||||
var w uint32
|
|
||||||
var x short
|
|
||||||
cursor := csbi.cursorPosition
|
|
||||||
x = csbi.size.x
|
|
||||||
_, _, _ = procFillConsoleOutputCharacter.Call(uintptr(handle), uintptr(' '), uintptr(x), uintptr(*(*int32)(unsafe.Pointer(&cursor))), uintptr(unsafe.Pointer(&w)))
|
|
||||||
}
|
|
||||||
7
vendor/github.com/atomicgo/cursor/doc.go
generated
vendored
7
vendor/github.com/atomicgo/cursor/doc.go
generated
vendored
@@ -1,7 +0,0 @@
|
|||||||
/*
|
|
||||||
Package cursor contains cross-platform methods to move the terminal cursor in different directions.
|
|
||||||
This package can be used to create interactive CLI tools and games, live charts, algorithm visualizations and other updatable output of any kind.
|
|
||||||
|
|
||||||
Special thanks to github.com/k0kubun/go-ansi which this project is based on.
|
|
||||||
*/
|
|
||||||
package cursor
|
|
||||||
3
vendor/github.com/atomicgo/cursor/go.mod
generated
vendored
3
vendor/github.com/atomicgo/cursor/go.mod
generated
vendored
@@ -1,3 +0,0 @@
|
|||||||
module github.com/atomicgo/cursor
|
|
||||||
|
|
||||||
go 1.15
|
|
||||||
0
vendor/github.com/atomicgo/cursor/go.sum
generated
vendored
0
vendor/github.com/atomicgo/cursor/go.sum
generated
vendored
43
vendor/github.com/atomicgo/cursor/syscall_windows.go
generated
vendored
43
vendor/github.com/atomicgo/cursor/syscall_windows.go
generated
vendored
@@ -1,43 +0,0 @@
|
|||||||
package cursor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"syscall"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
|
||||||
procFillConsoleOutputCharacter = kernel32.NewProc("FillConsoleOutputCharacterW")
|
|
||||||
procGetConsoleCursorInfo = kernel32.NewProc("GetConsoleCursorInfo")
|
|
||||||
procGetConsoleScreenBufferInfo = kernel32.NewProc("GetConsoleScreenBufferInfo")
|
|
||||||
procSetConsoleCursorInfo = kernel32.NewProc("SetConsoleCursorInfo")
|
|
||||||
procSetConsoleCursorPosition = kernel32.NewProc("SetConsoleCursorPosition")
|
|
||||||
)
|
|
||||||
|
|
||||||
type short int16
|
|
||||||
type dword uint32
|
|
||||||
type word uint16
|
|
||||||
|
|
||||||
type coord struct {
|
|
||||||
x short
|
|
||||||
y short
|
|
||||||
}
|
|
||||||
|
|
||||||
type smallRect struct {
|
|
||||||
bottom short
|
|
||||||
left short
|
|
||||||
right short
|
|
||||||
top short
|
|
||||||
}
|
|
||||||
|
|
||||||
type consoleScreenBufferInfo struct {
|
|
||||||
size coord
|
|
||||||
cursorPosition coord
|
|
||||||
attributes word
|
|
||||||
window smallRect
|
|
||||||
maximumWindowSize coord
|
|
||||||
}
|
|
||||||
|
|
||||||
type consoleCursorInfo struct {
|
|
||||||
size dword
|
|
||||||
visible int32
|
|
||||||
}
|
|
||||||
73
vendor/github.com/atomicgo/cursor/utils.go
generated
vendored
73
vendor/github.com/atomicgo/cursor/utils.go
generated
vendored
@@ -1,73 +0,0 @@
|
|||||||
package cursor
|
|
||||||
|
|
||||||
var height int
|
|
||||||
|
|
||||||
// Bottom moves the cursor to the bottom of the terminal.
|
|
||||||
// This is done by calculating how many lines were moved by Up and Down.
|
|
||||||
func Bottom() {
|
|
||||||
if height > 0 {
|
|
||||||
Down(height)
|
|
||||||
StartOfLine()
|
|
||||||
height = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartOfLine moves the cursor to the start of the current line.
|
|
||||||
func StartOfLine() {
|
|
||||||
HorizontalAbsolute(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartOfLineDown moves the cursor down by n lines, then moves to cursor to the start of the line.
|
|
||||||
func StartOfLineDown(n int) {
|
|
||||||
Down(n)
|
|
||||||
StartOfLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartOfLineUp moves the cursor up by n lines, then moves to cursor to the start of the line.
|
|
||||||
func StartOfLineUp(n int) {
|
|
||||||
Up(n)
|
|
||||||
StartOfLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpAndClear moves the cursor up by n lines, then clears the line.
|
|
||||||
func UpAndClear(n int) {
|
|
||||||
Up(n)
|
|
||||||
ClearLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DownAndClear moves the cursor down by n lines, then clears the line.
|
|
||||||
func DownAndClear(n int) {
|
|
||||||
Down(n)
|
|
||||||
ClearLine()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move moves the cursor relative by x and y.
|
|
||||||
func Move(x, y int) {
|
|
||||||
if x > 0 {
|
|
||||||
Right(x)
|
|
||||||
} else if x < 0 {
|
|
||||||
x *= -1
|
|
||||||
Left(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
if y > 0 {
|
|
||||||
Up(y)
|
|
||||||
} else if y < 0 {
|
|
||||||
y *= -1
|
|
||||||
Down(y)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearLinesUp clears n lines upwards from the current position and moves the cursor.
|
|
||||||
func ClearLinesUp(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
UpAndClear(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearLinesDown clears n lines downwards from the current position and moves the cursor.
|
|
||||||
func ClearLinesDown(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
DownAndClear(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
vendor/github.com/cpuguy83/go-md2man/v2/LICENSE.md
generated
vendored
21
vendor/github.com/cpuguy83/go-md2man/v2/LICENSE.md
generated
vendored
@@ -1,21 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2014 Brian Goff
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
14
vendor/github.com/cpuguy83/go-md2man/v2/md2man/md2man.go
generated
vendored
14
vendor/github.com/cpuguy83/go-md2man/v2/md2man/md2man.go
generated
vendored
@@ -1,14 +0,0 @@
|
|||||||
package md2man
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/russross/blackfriday/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Render converts a markdown document into a roff formatted document.
|
|
||||||
func Render(doc []byte) []byte {
|
|
||||||
renderer := NewRoffRenderer()
|
|
||||||
|
|
||||||
return blackfriday.Run(doc,
|
|
||||||
[]blackfriday.Option{blackfriday.WithRenderer(renderer),
|
|
||||||
blackfriday.WithExtensions(renderer.GetExtensions())}...)
|
|
||||||
}
|
|
||||||
345
vendor/github.com/cpuguy83/go-md2man/v2/md2man/roff.go
generated
vendored
345
vendor/github.com/cpuguy83/go-md2man/v2/md2man/roff.go
generated
vendored
@@ -1,345 +0,0 @@
|
|||||||
package md2man
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/russross/blackfriday/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// roffRenderer implements the blackfriday.Renderer interface for creating
|
|
||||||
// roff format (manpages) from markdown text
|
|
||||||
type roffRenderer struct {
|
|
||||||
extensions blackfriday.Extensions
|
|
||||||
listCounters []int
|
|
||||||
firstHeader bool
|
|
||||||
defineTerm bool
|
|
||||||
listDepth int
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
titleHeader = ".TH "
|
|
||||||
topLevelHeader = "\n\n.SH "
|
|
||||||
secondLevelHdr = "\n.SH "
|
|
||||||
otherHeader = "\n.SS "
|
|
||||||
crTag = "\n"
|
|
||||||
emphTag = "\\fI"
|
|
||||||
emphCloseTag = "\\fP"
|
|
||||||
strongTag = "\\fB"
|
|
||||||
strongCloseTag = "\\fP"
|
|
||||||
breakTag = "\n.br\n"
|
|
||||||
paraTag = "\n.PP\n"
|
|
||||||
hruleTag = "\n.ti 0\n\\l'\\n(.lu'\n"
|
|
||||||
linkTag = "\n\\[la]"
|
|
||||||
linkCloseTag = "\\[ra]"
|
|
||||||
codespanTag = "\\fB\\fC"
|
|
||||||
codespanCloseTag = "\\fR"
|
|
||||||
codeTag = "\n.PP\n.RS\n\n.nf\n"
|
|
||||||
codeCloseTag = "\n.fi\n.RE\n"
|
|
||||||
quoteTag = "\n.PP\n.RS\n"
|
|
||||||
quoteCloseTag = "\n.RE\n"
|
|
||||||
listTag = "\n.RS\n"
|
|
||||||
listCloseTag = "\n.RE\n"
|
|
||||||
arglistTag = "\n.TP\n"
|
|
||||||
tableStart = "\n.TS\nallbox;\n"
|
|
||||||
tableEnd = ".TE\n"
|
|
||||||
tableCellStart = "T{\n"
|
|
||||||
tableCellEnd = "\nT}\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
// NewRoffRenderer creates a new blackfriday Renderer for generating roff documents
|
|
||||||
// from markdown
|
|
||||||
func NewRoffRenderer() *roffRenderer { // nolint: golint
|
|
||||||
var extensions blackfriday.Extensions
|
|
||||||
|
|
||||||
extensions |= blackfriday.NoIntraEmphasis
|
|
||||||
extensions |= blackfriday.Tables
|
|
||||||
extensions |= blackfriday.FencedCode
|
|
||||||
extensions |= blackfriday.SpaceHeadings
|
|
||||||
extensions |= blackfriday.Footnotes
|
|
||||||
extensions |= blackfriday.Titleblock
|
|
||||||
extensions |= blackfriday.DefinitionLists
|
|
||||||
return &roffRenderer{
|
|
||||||
extensions: extensions,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetExtensions returns the list of extensions used by this renderer implementation
|
|
||||||
func (r *roffRenderer) GetExtensions() blackfriday.Extensions {
|
|
||||||
return r.extensions
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderHeader handles outputting the header at document start
|
|
||||||
func (r *roffRenderer) RenderHeader(w io.Writer, ast *blackfriday.Node) {
|
|
||||||
// disable hyphenation
|
|
||||||
out(w, ".nh\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderFooter handles outputting the footer at the document end; the roff
|
|
||||||
// renderer has no footer information
|
|
||||||
func (r *roffRenderer) RenderFooter(w io.Writer, ast *blackfriday.Node) {
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderNode is called for each node in a markdown document; based on the node
|
|
||||||
// type the equivalent roff output is sent to the writer
|
|
||||||
func (r *roffRenderer) RenderNode(w io.Writer, node *blackfriday.Node, entering bool) blackfriday.WalkStatus {
|
|
||||||
|
|
||||||
var walkAction = blackfriday.GoToNext
|
|
||||||
|
|
||||||
switch node.Type {
|
|
||||||
case blackfriday.Text:
|
|
||||||
r.handleText(w, node, entering)
|
|
||||||
case blackfriday.Softbreak:
|
|
||||||
out(w, crTag)
|
|
||||||
case blackfriday.Hardbreak:
|
|
||||||
out(w, breakTag)
|
|
||||||
case blackfriday.Emph:
|
|
||||||
if entering {
|
|
||||||
out(w, emphTag)
|
|
||||||
} else {
|
|
||||||
out(w, emphCloseTag)
|
|
||||||
}
|
|
||||||
case blackfriday.Strong:
|
|
||||||
if entering {
|
|
||||||
out(w, strongTag)
|
|
||||||
} else {
|
|
||||||
out(w, strongCloseTag)
|
|
||||||
}
|
|
||||||
case blackfriday.Link:
|
|
||||||
if !entering {
|
|
||||||
out(w, linkTag+string(node.LinkData.Destination)+linkCloseTag)
|
|
||||||
}
|
|
||||||
case blackfriday.Image:
|
|
||||||
// ignore images
|
|
||||||
walkAction = blackfriday.SkipChildren
|
|
||||||
case blackfriday.Code:
|
|
||||||
out(w, codespanTag)
|
|
||||||
escapeSpecialChars(w, node.Literal)
|
|
||||||
out(w, codespanCloseTag)
|
|
||||||
case blackfriday.Document:
|
|
||||||
break
|
|
||||||
case blackfriday.Paragraph:
|
|
||||||
// roff .PP markers break lists
|
|
||||||
if r.listDepth > 0 {
|
|
||||||
return blackfriday.GoToNext
|
|
||||||
}
|
|
||||||
if entering {
|
|
||||||
out(w, paraTag)
|
|
||||||
} else {
|
|
||||||
out(w, crTag)
|
|
||||||
}
|
|
||||||
case blackfriday.BlockQuote:
|
|
||||||
if entering {
|
|
||||||
out(w, quoteTag)
|
|
||||||
} else {
|
|
||||||
out(w, quoteCloseTag)
|
|
||||||
}
|
|
||||||
case blackfriday.Heading:
|
|
||||||
r.handleHeading(w, node, entering)
|
|
||||||
case blackfriday.HorizontalRule:
|
|
||||||
out(w, hruleTag)
|
|
||||||
case blackfriday.List:
|
|
||||||
r.handleList(w, node, entering)
|
|
||||||
case blackfriday.Item:
|
|
||||||
r.handleItem(w, node, entering)
|
|
||||||
case blackfriday.CodeBlock:
|
|
||||||
out(w, codeTag)
|
|
||||||
escapeSpecialChars(w, node.Literal)
|
|
||||||
out(w, codeCloseTag)
|
|
||||||
case blackfriday.Table:
|
|
||||||
r.handleTable(w, node, entering)
|
|
||||||
case blackfriday.TableCell:
|
|
||||||
r.handleTableCell(w, node, entering)
|
|
||||||
case blackfriday.TableHead:
|
|
||||||
case blackfriday.TableBody:
|
|
||||||
case blackfriday.TableRow:
|
|
||||||
// no action as cell entries do all the nroff formatting
|
|
||||||
return blackfriday.GoToNext
|
|
||||||
default:
|
|
||||||
fmt.Fprintln(os.Stderr, "WARNING: go-md2man does not handle node type "+node.Type.String())
|
|
||||||
}
|
|
||||||
return walkAction
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *roffRenderer) handleText(w io.Writer, node *blackfriday.Node, entering bool) {
|
|
||||||
var (
|
|
||||||
start, end string
|
|
||||||
)
|
|
||||||
// handle special roff table cell text encapsulation
|
|
||||||
if node.Parent.Type == blackfriday.TableCell {
|
|
||||||
if len(node.Literal) > 30 {
|
|
||||||
start = tableCellStart
|
|
||||||
end = tableCellEnd
|
|
||||||
} else {
|
|
||||||
// end rows that aren't terminated by "tableCellEnd" with a cr if end of row
|
|
||||||
if node.Parent.Next == nil && !node.Parent.IsHeader {
|
|
||||||
end = crTag
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out(w, start)
|
|
||||||
escapeSpecialChars(w, node.Literal)
|
|
||||||
out(w, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *roffRenderer) handleHeading(w io.Writer, node *blackfriday.Node, entering bool) {
|
|
||||||
if entering {
|
|
||||||
switch node.Level {
|
|
||||||
case 1:
|
|
||||||
if !r.firstHeader {
|
|
||||||
out(w, titleHeader)
|
|
||||||
r.firstHeader = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
out(w, topLevelHeader)
|
|
||||||
case 2:
|
|
||||||
out(w, secondLevelHdr)
|
|
||||||
default:
|
|
||||||
out(w, otherHeader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *roffRenderer) handleList(w io.Writer, node *blackfriday.Node, entering bool) {
|
|
||||||
openTag := listTag
|
|
||||||
closeTag := listCloseTag
|
|
||||||
if node.ListFlags&blackfriday.ListTypeDefinition != 0 {
|
|
||||||
// tags for definition lists handled within Item node
|
|
||||||
openTag = ""
|
|
||||||
closeTag = ""
|
|
||||||
}
|
|
||||||
if entering {
|
|
||||||
r.listDepth++
|
|
||||||
if node.ListFlags&blackfriday.ListTypeOrdered != 0 {
|
|
||||||
r.listCounters = append(r.listCounters, 1)
|
|
||||||
}
|
|
||||||
out(w, openTag)
|
|
||||||
} else {
|
|
||||||
if node.ListFlags&blackfriday.ListTypeOrdered != 0 {
|
|
||||||
r.listCounters = r.listCounters[:len(r.listCounters)-1]
|
|
||||||
}
|
|
||||||
out(w, closeTag)
|
|
||||||
r.listDepth--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *roffRenderer) handleItem(w io.Writer, node *blackfriday.Node, entering bool) {
|
|
||||||
if entering {
|
|
||||||
if node.ListFlags&blackfriday.ListTypeOrdered != 0 {
|
|
||||||
out(w, fmt.Sprintf(".IP \"%3d.\" 5\n", r.listCounters[len(r.listCounters)-1]))
|
|
||||||
r.listCounters[len(r.listCounters)-1]++
|
|
||||||
} else if node.ListFlags&blackfriday.ListTypeDefinition != 0 {
|
|
||||||
// state machine for handling terms and following definitions
|
|
||||||
// since blackfriday does not distinguish them properly, nor
|
|
||||||
// does it seperate them into separate lists as it should
|
|
||||||
if !r.defineTerm {
|
|
||||||
out(w, arglistTag)
|
|
||||||
r.defineTerm = true
|
|
||||||
} else {
|
|
||||||
r.defineTerm = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out(w, ".IP \\(bu 2\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
out(w, "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *roffRenderer) handleTable(w io.Writer, node *blackfriday.Node, entering bool) {
|
|
||||||
if entering {
|
|
||||||
out(w, tableStart)
|
|
||||||
//call walker to count cells (and rows?) so format section can be produced
|
|
||||||
columns := countColumns(node)
|
|
||||||
out(w, strings.Repeat("l ", columns)+"\n")
|
|
||||||
out(w, strings.Repeat("l ", columns)+".\n")
|
|
||||||
} else {
|
|
||||||
out(w, tableEnd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *roffRenderer) handleTableCell(w io.Writer, node *blackfriday.Node, entering bool) {
|
|
||||||
var (
|
|
||||||
start, end string
|
|
||||||
)
|
|
||||||
if node.IsHeader {
|
|
||||||
start = codespanTag
|
|
||||||
end = codespanCloseTag
|
|
||||||
}
|
|
||||||
if entering {
|
|
||||||
if node.Prev != nil && node.Prev.Type == blackfriday.TableCell {
|
|
||||||
out(w, "\t"+start)
|
|
||||||
} else {
|
|
||||||
out(w, start)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// need to carriage return if we are at the end of the header row
|
|
||||||
if node.IsHeader && node.Next == nil {
|
|
||||||
end = end + crTag
|
|
||||||
}
|
|
||||||
out(w, end)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// because roff format requires knowing the column count before outputting any table
|
|
||||||
// data we need to walk a table tree and count the columns
|
|
||||||
func countColumns(node *blackfriday.Node) int {
|
|
||||||
var columns int
|
|
||||||
|
|
||||||
node.Walk(func(node *blackfriday.Node, entering bool) blackfriday.WalkStatus {
|
|
||||||
switch node.Type {
|
|
||||||
case blackfriday.TableRow:
|
|
||||||
if !entering {
|
|
||||||
return blackfriday.Terminate
|
|
||||||
}
|
|
||||||
case blackfriday.TableCell:
|
|
||||||
if entering {
|
|
||||||
columns++
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return blackfriday.GoToNext
|
|
||||||
})
|
|
||||||
return columns
|
|
||||||
}
|
|
||||||
|
|
||||||
func out(w io.Writer, output string) {
|
|
||||||
io.WriteString(w, output) // nolint: errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
func needsBackslash(c byte) bool {
|
|
||||||
for _, r := range []byte("-_&\\~") {
|
|
||||||
if c == r {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func escapeSpecialChars(w io.Writer, text []byte) {
|
|
||||||
for i := 0; i < len(text); i++ {
|
|
||||||
// escape initial apostrophe or period
|
|
||||||
if len(text) >= 1 && (text[0] == '\'' || text[0] == '.') {
|
|
||||||
out(w, "\\&")
|
|
||||||
}
|
|
||||||
|
|
||||||
// directly copy normal characters
|
|
||||||
org := i
|
|
||||||
|
|
||||||
for i < len(text) && !needsBackslash(text[i]) {
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
if i > org {
|
|
||||||
w.Write(text[org:i]) // nolint: errcheck
|
|
||||||
}
|
|
||||||
|
|
||||||
// escape a character
|
|
||||||
if i >= len(text) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Write([]byte{'\\', text[i]}) // nolint: errcheck
|
|
||||||
}
|
|
||||||
}
|
|
||||||
20
vendor/github.com/gookit/color/.gitignore
generated
vendored
20
vendor/github.com/gookit/color/.gitignore
generated
vendored
@@ -1,20 +0,0 @@
|
|||||||
*.log
|
|
||||||
*.swp
|
|
||||||
.idea
|
|
||||||
*.patch
|
|
||||||
### Go template
|
|
||||||
# Binaries for programs and plugins
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, build with `go test -c`
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
|
||||||
.DS_Store
|
|
||||||
app
|
|
||||||
demo
|
|
||||||
20
vendor/github.com/gookit/color/LICENSE
generated
vendored
20
vendor/github.com/gookit/color/LICENSE
generated
vendored
@@ -1,20 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2016 inhere
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
||||||
this software and associated documentation files (the "Software"), to deal in
|
|
||||||
the Software without restriction, including without limitation the rights to
|
|
||||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
||||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
||||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
||||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
||||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
468
vendor/github.com/gookit/color/README.md
generated
vendored
468
vendor/github.com/gookit/color/README.md
generated
vendored
@@ -1,468 +0,0 @@
|
|||||||
# CLI Color
|
|
||||||
|
|
||||||

|
|
||||||
[](https://github.com/gookit/color/actions)
|
|
||||||
[](https://app.codacy.com/app/inhere/color)
|
|
||||||
[](https://pkg.go.dev/github.com/gookit/color?tab=overview)
|
|
||||||
[](https://github.com/gookit/color)
|
|
||||||
[](https://travis-ci.org/gookit/color)
|
|
||||||
[](https://coveralls.io/github/gookit/color?branch=master)
|
|
||||||
[](https://goreportcard.com/report/github.com/gookit/color)
|
|
||||||
|
|
||||||
A command-line color library with true color support, universal API methods and Windows support.
|
|
||||||
|
|
||||||
> **[中文说明](README.zh-CN.md)**
|
|
||||||
|
|
||||||
Basic color preview:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Now, 256 colors and RGB colors have also been supported to work in Windows CMD and PowerShell:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- Simple to use, zero dependencies
|
|
||||||
- Supports rich color output: 16-color (4-bit), 256-color (8-bit), true color (24-bit, RGB)
|
|
||||||
- 16-color output is the most commonly used and most widely supported, working on any Windows version
|
|
||||||
- Since `v1.2.4` **the 256-color (8-bit), true color (24-bit) support windows CMD and PowerShell**
|
|
||||||
- See [this gist](https://gist.github.com/XVilka/8346728) for information on true color support
|
|
||||||
- Generic API methods: `Print`, `Printf`, `Println`, `Sprint`, `Sprintf`
|
|
||||||
- Supports HTML tag-style color rendering, such as `<green>message</>`.
|
|
||||||
- In addition to using built-in tags, it also supports custom color attributes
|
|
||||||
- Custom color attributes support the use of 16 color names, 256 color values, rgb color values and hex color values
|
|
||||||
- Support working on Windows `cmd` and `powerShell` terminal
|
|
||||||
- Basic colors: `Bold`, `Black`, `White`, `Gray`, `Red`, `Green`, `Yellow`, `Blue`, `Magenta`, `Cyan`
|
|
||||||
- Additional styles: `Info`, `Note`, `Light`, `Error`, `Danger`, `Notice`, `Success`, `Comment`, `Primary`, `Warning`, `Question`, `Secondary`
|
|
||||||
- Support by set `NO_COLOR` for disable color or use `FORCE_COLOR` for force open color render.
|
|
||||||
- Support Rgb, 256, 16 color conversion
|
|
||||||
|
|
||||||
## GoDoc
|
|
||||||
|
|
||||||
- [godoc for gopkg](https://pkg.go.dev/gopkg.in/gookit/color.v1)
|
|
||||||
- [godoc for github](https://pkg.go.dev/github.com/gookit/color)
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go get github.com/gookit/color
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/gookit/color"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// quick use package func
|
|
||||||
color.Redp("Simple to use color")
|
|
||||||
color.Redln("Simple to use color")
|
|
||||||
color.Greenp("Simple to use color\n")
|
|
||||||
color.Cyanln("Simple to use color")
|
|
||||||
color.Yellowln("Simple to use color")
|
|
||||||
|
|
||||||
// quick use like fmt.Print*
|
|
||||||
color.Red.Println("Simple to use color")
|
|
||||||
color.Green.Print("Simple to use color\n")
|
|
||||||
color.Cyan.Printf("Simple to use %s\n", "color")
|
|
||||||
color.Yellow.Printf("Simple to use %s\n", "color")
|
|
||||||
|
|
||||||
// use like func
|
|
||||||
red := color.FgRed.Render
|
|
||||||
green := color.FgGreen.Render
|
|
||||||
fmt.Printf("%s line %s library\n", red("Command"), green("color"))
|
|
||||||
|
|
||||||
// custom color
|
|
||||||
color.New(color.FgWhite, color.BgBlack).Println("custom color style")
|
|
||||||
|
|
||||||
// can also:
|
|
||||||
color.Style{color.FgCyan, color.OpBold}.Println("custom color style")
|
|
||||||
|
|
||||||
// internal theme/style:
|
|
||||||
color.Info.Tips("message")
|
|
||||||
color.Info.Prompt("message")
|
|
||||||
color.Info.Println("message")
|
|
||||||
color.Warn.Println("message")
|
|
||||||
color.Error.Println("message")
|
|
||||||
|
|
||||||
// use style tag
|
|
||||||
color.Print("<suc>he</><comment>llo</>, <cyan>wel</><red>come</>\n")
|
|
||||||
// Custom label attr: Supports the use of 16 color names, 256 color values, rgb color values and hex color values
|
|
||||||
color.Println("<fg=11aa23>he</><bg=120,35,156>llo</>, <fg=167;bg=232>wel</><fg=red>come</>")
|
|
||||||
|
|
||||||
// apply a style tag
|
|
||||||
color.Tag("info").Println("info style text")
|
|
||||||
|
|
||||||
// prompt message
|
|
||||||
color.Info.Prompt("prompt style message")
|
|
||||||
color.Warn.Prompt("prompt style message")
|
|
||||||
|
|
||||||
// tips message
|
|
||||||
color.Info.Tips("tips style message")
|
|
||||||
color.Warn.Tips("tips style message")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/demo.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Basic/16 color
|
|
||||||
|
|
||||||
Supported on any Windows version. Provide generic API methods: `Print`, `Printf`, `Println`, `Sprint`, `Sprintf`
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Bold.Println("bold message")
|
|
||||||
color.Black.Println("bold message")
|
|
||||||
color.White.Println("bold message")
|
|
||||||
color.Gray.Println("bold message")
|
|
||||||
color.Red.Println("yellow message")
|
|
||||||
color.Blue.Println("yellow message")
|
|
||||||
color.Cyan.Println("yellow message")
|
|
||||||
color.Yellow.Println("yellow message")
|
|
||||||
color.Magenta.Println("yellow message")
|
|
||||||
|
|
||||||
// Only use foreground color
|
|
||||||
color.FgCyan.Printf("Simple to use %s\n", "color")
|
|
||||||
// Only use background color
|
|
||||||
color.BgRed.Printf("Simple to use %s\n", "color")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/color_16.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### Custom build color
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Full custom: foreground, background, option
|
|
||||||
myStyle := color.New(color.FgWhite, color.BgBlack, color.OpBold)
|
|
||||||
myStyle.Println("custom color style")
|
|
||||||
|
|
||||||
// can also:
|
|
||||||
color.Style{color.FgCyan, color.OpBold}.Println("custom color style")
|
|
||||||
```
|
|
||||||
|
|
||||||
custom set console settings:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// set console color
|
|
||||||
color.Set(color.FgCyan)
|
|
||||||
|
|
||||||
// print message
|
|
||||||
fmt.Print("message")
|
|
||||||
|
|
||||||
// reset console settings
|
|
||||||
color.Reset()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Additional styles
|
|
||||||
|
|
||||||
provide generic API methods: `Print`, `Printf`, `Println`, `Sprint`, `Sprintf`
|
|
||||||
|
|
||||||
print message use defined style:
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Info.Println("Info message")
|
|
||||||
color.Note.Println("Note message")
|
|
||||||
color.Notice.Println("Notice message")
|
|
||||||
color.Error.Println("Error message")
|
|
||||||
color.Danger.Println("Danger message")
|
|
||||||
color.Warn.Println("Warn message")
|
|
||||||
color.Debug.Println("Debug message")
|
|
||||||
color.Primary.Println("Primary message")
|
|
||||||
color.Question.Println("Question message")
|
|
||||||
color.Secondary.Println("Secondary message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_basic.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Tips style**
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Info.Tips("Info tips message")
|
|
||||||
color.Note.Tips("Note tips message")
|
|
||||||
color.Notice.Tips("Notice tips message")
|
|
||||||
color.Error.Tips("Error tips message")
|
|
||||||
color.Danger.Tips("Danger tips message")
|
|
||||||
color.Warn.Tips("Warn tips message")
|
|
||||||
color.Debug.Tips("Debug tips message")
|
|
||||||
color.Primary.Tips("Primary tips message")
|
|
||||||
color.Question.Tips("Question tips message")
|
|
||||||
color.Secondary.Tips("Secondary tips message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_tips.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Prompt Style**
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Info.Prompt("Info prompt message")
|
|
||||||
color.Note.Prompt("Note prompt message")
|
|
||||||
color.Notice.Prompt("Notice prompt message")
|
|
||||||
color.Error.Prompt("Error prompt message")
|
|
||||||
color.Danger.Prompt("Danger prompt message")
|
|
||||||
color.Warn.Prompt("Warn prompt message")
|
|
||||||
color.Debug.Prompt("Debug prompt message")
|
|
||||||
color.Primary.Prompt("Primary prompt message")
|
|
||||||
color.Question.Prompt("Question prompt message")
|
|
||||||
color.Secondary.Prompt("Secondary prompt message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_prompt.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**Block Style**
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Info.Block("Info block message")
|
|
||||||
color.Note.Block("Note block message")
|
|
||||||
color.Notice.Block("Notice block message")
|
|
||||||
color.Error.Block("Error block message")
|
|
||||||
color.Danger.Block("Danger block message")
|
|
||||||
color.Warn.Block("Warn block message")
|
|
||||||
color.Debug.Block("Debug block message")
|
|
||||||
color.Primary.Block("Primary block message")
|
|
||||||
color.Question.Block("Question block message")
|
|
||||||
color.Secondary.Block("Secondary block message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_block.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 256-color usage
|
|
||||||
|
|
||||||
> 256 colors support Windows CMD, PowerShell environment after `v1.2.4`
|
|
||||||
|
|
||||||
### Set the foreground or background color
|
|
||||||
|
|
||||||
- `color.C256(val uint8, isBg ...bool) Color256`
|
|
||||||
|
|
||||||
```go
|
|
||||||
c := color.C256(132) // fg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
|
|
||||||
c := color.C256(132, true) // bg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 256-color style
|
|
||||||
|
|
||||||
Can be used to set foreground and background colors at the same time.
|
|
||||||
|
|
||||||
- `S256(fgAndBg ...uint8) *Style256`
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.S256(32, 203)
|
|
||||||
s.Println("message")
|
|
||||||
s.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
with options:
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.S256(32, 203)
|
|
||||||
s.SetOpts(color.Opts{color.OpBold})
|
|
||||||
|
|
||||||
s.Println("style with options")
|
|
||||||
s.Printf("style with %s\n", "options")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/color_256.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## RGB/True color
|
|
||||||
|
|
||||||
> RGB colors support Windows `CMD`, `PowerShell` environment after `v1.2.4`
|
|
||||||
|
|
||||||
**Preview:**
|
|
||||||
|
|
||||||
> Run demo: `Run demo: go run ./_examples/color_rgb.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
example:
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.RGB(30, 144, 255).Println("message. use RGB number")
|
|
||||||
|
|
||||||
color.HEX("#1976D2").Println("blue-darken")
|
|
||||||
color.HEX("#D50000", true).Println("red-accent. use HEX style")
|
|
||||||
|
|
||||||
color.RGBStyleFromString("213,0,0").Println("red-accent. use RGB number")
|
|
||||||
color.HEXStyle("eee", "D50000").Println("deep-purple color")
|
|
||||||
```
|
|
||||||
|
|
||||||
### Set the foreground or background color
|
|
||||||
|
|
||||||
- `color.RGB(r, g, b uint8, isBg ...bool) RGBColor`
|
|
||||||
|
|
||||||
```go
|
|
||||||
c := color.RGB(30,144,255) // fg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
|
|
||||||
c := color.RGB(30,144,255, true) // bg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a style from an hexadecimal color string:
|
|
||||||
|
|
||||||
- `color.HEX(hex string, isBg ...bool) RGBColor`
|
|
||||||
|
|
||||||
```go
|
|
||||||
c := color.HEX("ccc") // can also: "cccccc" "#cccccc"
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
|
|
||||||
c = color.HEX("aabbcc", true) // as bg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
### RGB color style
|
|
||||||
|
|
||||||
Can be used to set the foreground and background colors at the same time.
|
|
||||||
|
|
||||||
- `color.NewRGBStyle(fg RGBColor, bg ...RGBColor) *RGBStyle`
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.NewRGBStyle(RGB(20, 144, 234), RGB(234, 78, 23))
|
|
||||||
s.Println("message")
|
|
||||||
s.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a style from an hexadecimal color string:
|
|
||||||
|
|
||||||
- `color.HEXStyle(fg string, bg ...string) *RGBStyle`
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.HEXStyle("11aa23", "eee")
|
|
||||||
s.Println("message")
|
|
||||||
s.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
with options:
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.HEXStyle("11aa23", "eee")
|
|
||||||
s.SetOpts(color.Opts{color.OpBold})
|
|
||||||
|
|
||||||
s.Println("style with options")
|
|
||||||
s.Printf("style with %s\n", "options")
|
|
||||||
```
|
|
||||||
|
|
||||||
## HTML-like tag usage
|
|
||||||
|
|
||||||
**Supported** on Windows `cmd.exe` `PowerShell` .
|
|
||||||
|
|
||||||
```go
|
|
||||||
// use style tag
|
|
||||||
color.Print("<suc>he</><comment>llo</>, <cyan>wel</><red>come</>")
|
|
||||||
color.Println("<suc>hello</>")
|
|
||||||
color.Println("<error>hello</>")
|
|
||||||
color.Println("<warning>hello</>")
|
|
||||||
|
|
||||||
// custom color attributes
|
|
||||||
color.Print("<fg=yellow;bg=black;op=underscore;>hello, welcome</>\n")
|
|
||||||
|
|
||||||
// Custom label attr: Supports the use of 16 color names, 256 color values, rgb color values and hex color values
|
|
||||||
color.Println("<fg=11aa23>he</><bg=120,35,156>llo</>, <fg=167;bg=232>wel</><fg=red>come</>")
|
|
||||||
```
|
|
||||||
|
|
||||||
- `color.Tag`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// set a style tag
|
|
||||||
color.Tag("info").Print("info style text")
|
|
||||||
color.Tag("info").Printf("%s style text", "info")
|
|
||||||
color.Tag("info").Println("info style text")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/color_tag.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## Color convert
|
|
||||||
|
|
||||||
Supports conversion between Rgb, 256, 16 colors, `Rgb <=> 256 <=> 16`
|
|
||||||
|
|
||||||
```go
|
|
||||||
basic := color.Red
|
|
||||||
basic.Println("basic color")
|
|
||||||
|
|
||||||
c256 := color.Red.C256()
|
|
||||||
c256.Println("256 color")
|
|
||||||
c256.C16().Println("basic color")
|
|
||||||
|
|
||||||
rgb := color.Red.RGB()
|
|
||||||
rgb.Println("rgb color")
|
|
||||||
rgb.C256().Println("256 color")
|
|
||||||
```
|
|
||||||
|
|
||||||
## Func refer
|
|
||||||
|
|
||||||
There are some useful functions reference
|
|
||||||
|
|
||||||
- `Disable()` disable color render
|
|
||||||
- `SetOutput(io.Writer)` custom set the colored text output writer
|
|
||||||
- `ForceOpenColor()` force open color render
|
|
||||||
- `Colors2code(colors ...Color) string` Convert colors to code. return like "32;45;3"
|
|
||||||
- `ClearCode(str string) string` Use for clear color codes
|
|
||||||
- `ClearTag(s string) string` clear all color html-tag for a string
|
|
||||||
- `IsConsole(w io.Writer)` Determine whether w is one of stderr, stdout, stdin
|
|
||||||
- `HexToRgb(hex string) (rgb []int)` Convert hex color string to RGB numbers
|
|
||||||
- `RgbToHex(rgb []int) string` Convert RGB to hex code
|
|
||||||
- More useful func please see https://pkg.go.dev/github.com/gookit/color
|
|
||||||
|
|
||||||
## Project use
|
|
||||||
|
|
||||||
Check out these projects, which use https://github.com/gookit/color :
|
|
||||||
|
|
||||||
- https://github.com/Delta456/box-cli-maker Make Highly Customized Boxes for your CLI
|
|
||||||
|
|
||||||
## Gookit packages
|
|
||||||
|
|
||||||
- [gookit/ini](https://github.com/gookit/ini) Go config management, use INI files
|
|
||||||
- [gookit/rux](https://github.com/gookit/rux) Simple and fast request router for golang HTTP
|
|
||||||
- [gookit/gcli](https://github.com/gookit/gcli) build CLI application, tool library, running CLI commands
|
|
||||||
- [gookit/slog](https://github.com/gookit/slog) Concise and extensible go log library
|
|
||||||
- [gookit/event](https://github.com/gookit/event) Lightweight event manager and dispatcher implements by Go
|
|
||||||
- [gookit/cache](https://github.com/gookit/cache) Generic cache use and cache manager for golang. support File, Memory, Redis, Memcached.
|
|
||||||
- [gookit/config](https://github.com/gookit/config) Go config management. support JSON, YAML, TOML, INI, HCL, ENV and Flags
|
|
||||||
- [gookit/color](https://github.com/gookit/color) A command-line color library with true color support, universal API methods and Windows support
|
|
||||||
- [gookit/filter](https://github.com/gookit/filter) Provide filtering, sanitizing, and conversion of golang data
|
|
||||||
- [gookit/validate](https://github.com/gookit/validate) Use for data validation and filtering. support Map, Struct, Form data
|
|
||||||
- [gookit/goutil](https://github.com/gookit/goutil) Some utils for the Go: string, array/slice, map, format, cli, env, filesystem, test and more
|
|
||||||
- More, please see https://github.com/gookit
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- [inhere/console](https://github.com/inhere/php-console)
|
|
||||||
- [xo/terminfo](https://github.com/xo/terminfo)
|
|
||||||
- [beego/bee](https://github.com/beego/bee)
|
|
||||||
- [issue9/term](https://github.com/issue9/term)
|
|
||||||
- [ANSI escape code](https://en.wikipedia.org/wiki/ANSI_escape_code)
|
|
||||||
- [Standard ANSI color map](https://conemu.github.io/en/AnsiEscapeCodes.html#Standard_ANSI_color_map)
|
|
||||||
- [Terminal Colors](https://gist.github.com/XVilka/8346728)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
[MIT](/LICENSE)
|
|
||||||
472
vendor/github.com/gookit/color/README.zh-CN.md
generated
vendored
472
vendor/github.com/gookit/color/README.zh-CN.md
generated
vendored
@@ -1,472 +0,0 @@
|
|||||||
# CLI Color
|
|
||||||
|
|
||||||

|
|
||||||
[](https://github.com/gookit/color/actions)
|
|
||||||
[](https://app.codacy.com/app/inhere/color)
|
|
||||||
[](https://pkg.go.dev/github.com/gookit/color?tab=overview)
|
|
||||||
[](https://github.com/gookit/color)
|
|
||||||
[](https://travis-ci.org/gookit/color)
|
|
||||||
[](https://coveralls.io/github/gookit/color?branch=master)
|
|
||||||
[](https://goreportcard.com/report/github.com/gookit/color)
|
|
||||||
|
|
||||||
Golang下的命令行色彩使用库, 拥有丰富的色彩渲染输出,通用的API方法,兼容Windows系统
|
|
||||||
|
|
||||||
> **[EN README](README.md)**
|
|
||||||
|
|
||||||
基本颜色预览:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
现在,256色和RGB色彩也已经支持windows CMD和PowerShell中工作:
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 功能特色
|
|
||||||
|
|
||||||
- 使用简单方便
|
|
||||||
- 支持丰富的颜色输出, 16色(4bit),256色(8bit),RGB色彩(24bit, RGB)
|
|
||||||
- 16色(4bit)是最常用和支持最广的,支持Windows `cmd.exe`
|
|
||||||
- 自 `v1.2.4` 起 **256色(8bit),RGB色彩(24bit)均支持Windows CMD和PowerShell终端**
|
|
||||||
- 请查看 [this gist](https://gist.github.com/XVilka/8346728) 了解支持RGB色彩的终端
|
|
||||||
- 提供通用的API方法:`Print` `Printf` `Println` `Sprint` `Sprintf`
|
|
||||||
- 同时支持html标签式的颜色渲染,除了使用内置标签,同时支持自定义颜色属性
|
|
||||||
- 例如: `this an <green>message</>` 标签内部的文本将会渲染为绿色字体
|
|
||||||
- 自定义颜色属性: 支持使用16色彩名称,256色彩值,rgb色彩值以及hex色彩值
|
|
||||||
- 基础色彩: `Bold` `Black` `White` `Gray` `Red` `Green` `Yellow` `Blue` `Magenta` `Cyan`
|
|
||||||
- 扩展风格: `Info` `Note` `Light` `Error` `Danger` `Notice` `Success` `Comment` `Primary` `Warning` `Question` `Secondary`
|
|
||||||
- 支持通过设置环境变量 `NO_COLOR` 来禁用色彩,或者使用 `FORCE_COLOR` 来强制使用色彩渲染.
|
|
||||||
- 支持 Rgb, 256, 16 色彩之间的互相转换
|
|
||||||
- 支持Linux、Mac,同时兼容Windows系统环境
|
|
||||||
|
|
||||||
## GoDoc
|
|
||||||
|
|
||||||
- [godoc for gopkg](https://pkg.go.dev/gopkg.in/gookit/color.v1)
|
|
||||||
- [godoc for github](https://pkg.go.dev/github.com/gookit/color)
|
|
||||||
|
|
||||||
## 安装
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go get github.com/gookit/color
|
|
||||||
```
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
如下,引入当前包就可以快速的使用
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/gookit/color"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// 简单快速的使用,跟 fmt.Print* 类似
|
|
||||||
color.Redp("Simple to use color")
|
|
||||||
color.Redln("Simple to use color")
|
|
||||||
color.Greenp("Simple to use color\n")
|
|
||||||
color.Cyanln("Simple to use color")
|
|
||||||
color.Yellowln("Simple to use color")
|
|
||||||
|
|
||||||
// 简单快速的使用,跟 fmt.Print* 类似
|
|
||||||
color.Red.Println("Simple to use color")
|
|
||||||
color.Green.Print("Simple to use color\n")
|
|
||||||
color.Cyan.Printf("Simple to use %s\n", "color")
|
|
||||||
color.Yellow.Printf("Simple to use %s\n", "color")
|
|
||||||
|
|
||||||
// use like func
|
|
||||||
red := color.FgRed.Render
|
|
||||||
green := color.FgGreen.Render
|
|
||||||
fmt.Printf("%s line %s library\n", red("Command"), green("color"))
|
|
||||||
|
|
||||||
// 自定义颜色
|
|
||||||
color.New(color.FgWhite, color.BgBlack).Println("custom color style")
|
|
||||||
|
|
||||||
// 也可以:
|
|
||||||
color.Style{color.FgCyan, color.OpBold}.Println("custom color style")
|
|
||||||
|
|
||||||
// internal style:
|
|
||||||
color.Info.Println("message")
|
|
||||||
color.Warn.Println("message")
|
|
||||||
color.Error.Println("message")
|
|
||||||
|
|
||||||
// 使用内置颜色标签
|
|
||||||
color.Print("<suc>he</><comment>llo</>, <cyan>wel</><red>come</>\n")
|
|
||||||
// 自定义标签: 支持使用16色彩名称,256色彩值,rgb色彩值以及hex色彩值
|
|
||||||
color.Println("<fg=11aa23>he</><bg=120,35,156>llo</>, <fg=167;bg=232>wel</><fg=red>come</>")
|
|
||||||
|
|
||||||
// apply a style tag
|
|
||||||
color.Tag("info").Println("info style text")
|
|
||||||
|
|
||||||
// prompt message
|
|
||||||
color.Info.Prompt("prompt style message")
|
|
||||||
color.Warn.Prompt("prompt style message")
|
|
||||||
|
|
||||||
// tips message
|
|
||||||
color.Info.Tips("tips style message")
|
|
||||||
color.Warn.Tips("tips style message")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> 运行 demo: `go run ./_examples/demo.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 基础颜色(16-color)
|
|
||||||
|
|
||||||
提供通用的API方法:`Print` `Printf` `Println` `Sprint` `Sprintf`
|
|
||||||
|
|
||||||
> 支持在windows `cmd.exe` `powerShell` 等终端使用
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Bold.Println("bold message")
|
|
||||||
color.Black.Println("bold message")
|
|
||||||
color.White.Println("bold message")
|
|
||||||
color.Gray.Println("bold message")
|
|
||||||
color.Red.Println("yellow message")
|
|
||||||
color.Blue.Println("yellow message")
|
|
||||||
color.Cyan.Println("yellow message")
|
|
||||||
color.Yellow.Println("yellow message")
|
|
||||||
color.Magenta.Println("yellow message")
|
|
||||||
|
|
||||||
// Only use foreground color
|
|
||||||
color.FgCyan.Printf("Simple to use %s\n", "color")
|
|
||||||
// Only use background color
|
|
||||||
color.BgRed.Printf("Simple to use %s\n", "color")
|
|
||||||
```
|
|
||||||
|
|
||||||
> 运行demo: `go run ./_examples/color_16.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
### 构建风格
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 仅设置前景色
|
|
||||||
color.FgCyan.Printf("Simple to use %s\n", "color")
|
|
||||||
// 仅设置背景色
|
|
||||||
color.BgRed.Printf("Simple to use %s\n", "color")
|
|
||||||
|
|
||||||
// 完全自定义: 前景色 背景色 选项
|
|
||||||
style := color.New(color.FgWhite, color.BgBlack, color.OpBold)
|
|
||||||
style.Println("custom color style")
|
|
||||||
|
|
||||||
// 也可以:
|
|
||||||
color.Style{color.FgCyan, color.OpBold}.Println("custom color style")
|
|
||||||
```
|
|
||||||
|
|
||||||
直接设置控制台属性:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 设置console颜色
|
|
||||||
color.Set(color.FgCyan)
|
|
||||||
|
|
||||||
// 输出信息
|
|
||||||
fmt.Print("message")
|
|
||||||
|
|
||||||
// 重置console颜色
|
|
||||||
color.Reset()
|
|
||||||
```
|
|
||||||
|
|
||||||
> 当然,color已经内置丰富的色彩风格支持
|
|
||||||
|
|
||||||
### 扩展风格方法
|
|
||||||
|
|
||||||
提供通用的API方法:`Print` `Printf` `Println` `Sprint` `Sprintf`
|
|
||||||
|
|
||||||
> 支持在windows `cmd.exe` `powerShell` 等终端使用
|
|
||||||
|
|
||||||
基础使用:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// print message
|
|
||||||
color.Info.Println("Info message")
|
|
||||||
color.Note.Println("Note message")
|
|
||||||
color.Notice.Println("Notice message")
|
|
||||||
color.Error.Println("Error message")
|
|
||||||
color.Danger.Println("Danger message")
|
|
||||||
color.Warn.Println("Warn message")
|
|
||||||
color.Debug.Println("Debug message")
|
|
||||||
color.Primary.Println("Primary message")
|
|
||||||
color.Question.Println("Question message")
|
|
||||||
color.Secondary.Println("Secondary message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_basic.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**简约提示风格**
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Info.Tips("Info tips message")
|
|
||||||
color.Note.Tips("Note tips message")
|
|
||||||
color.Notice.Tips("Notice tips message")
|
|
||||||
color.Error.Tips("Error tips message")
|
|
||||||
color.Danger.Tips("Danger tips message")
|
|
||||||
color.Warn.Tips("Warn tips message")
|
|
||||||
color.Debug.Tips("Debug tips message")
|
|
||||||
color.Primary.Tips("Primary tips message")
|
|
||||||
color.Question.Tips("Question tips message")
|
|
||||||
color.Secondary.Tips("Secondary tips message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_tips.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**着重提示风格**
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Info.Prompt("Info prompt message")
|
|
||||||
color.Note.Prompt("Note prompt message")
|
|
||||||
color.Notice.Prompt("Notice prompt message")
|
|
||||||
color.Error.Prompt("Error prompt message")
|
|
||||||
color.Danger.Prompt("Danger prompt message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_prompt.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
**强调提示风格**
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.Warn.Block("Warn block message")
|
|
||||||
color.Debug.Block("Debug block message")
|
|
||||||
color.Primary.Block("Primary block message")
|
|
||||||
color.Question.Block("Question block message")
|
|
||||||
color.Secondary.Block("Secondary block message")
|
|
||||||
```
|
|
||||||
|
|
||||||
Run demo: `go run ./_examples/theme_block.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 256 色彩使用
|
|
||||||
|
|
||||||
> 256色彩在 `v1.2.4` 后支持Windows CMD,PowerShell 环境
|
|
||||||
|
|
||||||
### 使用前景或后景色
|
|
||||||
|
|
||||||
- `color.C256(val uint8, isBg ...bool) Color256`
|
|
||||||
|
|
||||||
```go
|
|
||||||
c := color.C256(132) // fg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
|
|
||||||
c := color.C256(132, true) // bg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用256 色彩风格
|
|
||||||
|
|
||||||
> 可同时设置前景和背景色
|
|
||||||
|
|
||||||
- `color.S256(fgAndBg ...uint8) *Style256`
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.S256(32, 203)
|
|
||||||
s.Println("message")
|
|
||||||
s.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
可以同时添加选项设置:
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.S256(32, 203)
|
|
||||||
s.SetOpts(color.Opts{color.OpBold})
|
|
||||||
|
|
||||||
s.Println("style with options")
|
|
||||||
s.Printf("style with %s\n", "options")
|
|
||||||
```
|
|
||||||
|
|
||||||
> 运行 demo: `go run ./_examples/color_256.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## RGB/True色彩使用
|
|
||||||
|
|
||||||
> RGB色彩在 `v1.2.4` 后支持 Windows `CMD`, `PowerShell` 环境
|
|
||||||
|
|
||||||
**效果预览:**
|
|
||||||
|
|
||||||
> 运行 demo: `Run demo: go run ./_examples/color_rgb.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
代码示例:
|
|
||||||
|
|
||||||
```go
|
|
||||||
color.RGB(30, 144, 255).Println("message. use RGB number")
|
|
||||||
|
|
||||||
color.HEX("#1976D2").Println("blue-darken")
|
|
||||||
color.HEX("#D50000", true).Println("red-accent. use HEX style")
|
|
||||||
|
|
||||||
color.RGBStyleFromString("213,0,0").Println("red-accent. use RGB number")
|
|
||||||
color.HEXStyle("eee", "D50000").Println("deep-purple color")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用前景或后景色
|
|
||||||
|
|
||||||
- `color.RGB(r, g, b uint8, isBg ...bool) RGBColor`
|
|
||||||
|
|
||||||
```go
|
|
||||||
c := color.RGB(30,144,255) // fg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
|
|
||||||
c := color.RGB(30,144,255, true) // bg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
- `color.HEX(hex string, isBg ...bool) RGBColor` 从16进制颜色创建
|
|
||||||
|
|
||||||
```go
|
|
||||||
c := color.HEX("ccc") // 也可以写为: "cccccc" "#cccccc"
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
|
|
||||||
c = color.HEX("aabbcc", true) // as bg color
|
|
||||||
c.Println("message")
|
|
||||||
c.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
### 使用RGB风格
|
|
||||||
|
|
||||||
> 可同时设置前景和背景色
|
|
||||||
|
|
||||||
- `color.NewRGBStyle(fg RGBColor, bg ...RGBColor) *RGBStyle`
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.NewRGBStyle(RGB(20, 144, 234), RGB(234, 78, 23))
|
|
||||||
s.Println("message")
|
|
||||||
s.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
- `color.HEXStyle(fg string, bg ...string) *RGBStyle` 从16进制颜色创建
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.HEXStyle("11aa23", "eee")
|
|
||||||
s.Println("message")
|
|
||||||
s.Printf("format %s", "message")
|
|
||||||
```
|
|
||||||
|
|
||||||
- 可以同时添加选项设置:
|
|
||||||
|
|
||||||
```go
|
|
||||||
s := color.HEXStyle("11aa23", "eee")
|
|
||||||
s.SetOpts(color.Opts{color.OpBold})
|
|
||||||
|
|
||||||
s.Println("style with options")
|
|
||||||
s.Printf("style with %s\n", "options")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用颜色标签
|
|
||||||
|
|
||||||
> **支持** 在windows `cmd.exe` `PowerShell` 使用
|
|
||||||
|
|
||||||
使用内置的颜色标签,可以非常方便简单的构建自己需要的任何格式
|
|
||||||
|
|
||||||
> 同时支持自定义颜色属性: 支持使用16色彩名称,256色彩值,rgb色彩值以及hex色彩值
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 使用内置的 color tag
|
|
||||||
color.Print("<suc>he</><comment>llo</>, <cyan>wel</><red>come</>")
|
|
||||||
color.Println("<suc>hello</>")
|
|
||||||
color.Println("<error>hello</>")
|
|
||||||
color.Println("<warning>hello</>")
|
|
||||||
|
|
||||||
// 自定义颜色属性
|
|
||||||
color.Print("<fg=yellow;bg=black;op=underscore;>hello, welcome</>\n")
|
|
||||||
|
|
||||||
// 自定义颜色属性: 支持使用16色彩名称,256色彩值,rgb色彩值以及hex色彩值
|
|
||||||
color.Println("<fg=11aa23>he</><bg=120,35,156>llo</>, <fg=167;bg=232>wel</><fg=red>come</>")
|
|
||||||
```
|
|
||||||
|
|
||||||
- 使用 `color.Tag`
|
|
||||||
|
|
||||||
给后面输出的文本信息加上给定的颜色风格标签
|
|
||||||
|
|
||||||
```go
|
|
||||||
// set a style tag
|
|
||||||
color.Tag("info").Print("info style text")
|
|
||||||
color.Tag("info").Printf("%s style text", "info")
|
|
||||||
color.Tag("info").Println("info style text")
|
|
||||||
```
|
|
||||||
|
|
||||||
> 运行 demo: `go run ./_examples/color_tag.go`
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
## 颜色转换
|
|
||||||
|
|
||||||
支持 Rgb, 256, 16 色彩之间的互相转换 `Rgb <=> 256 <=> 16`
|
|
||||||
|
|
||||||
```go
|
|
||||||
basic := color.Red
|
|
||||||
basic.Println("basic color")
|
|
||||||
|
|
||||||
c256 := color.Red.C256()
|
|
||||||
c256.Println("256 color")
|
|
||||||
c256.C16().Println("basic color")
|
|
||||||
|
|
||||||
rgb := color.Red.RGB()
|
|
||||||
rgb.Println("rgb color")
|
|
||||||
rgb.C256().Println("256 color")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 方法参考
|
|
||||||
|
|
||||||
一些有用的工具方法参考
|
|
||||||
|
|
||||||
- `Disable()` disable color render
|
|
||||||
- `SetOutput(io.Writer)` custom set the colored text output writer
|
|
||||||
- `ForceOpenColor()` force open color render
|
|
||||||
- `ClearCode(str string) string` Use for clear color codes
|
|
||||||
- `Colors2code(colors ...Color) string` Convert colors to code. return like "32;45;3"
|
|
||||||
- `ClearTag(s string) string` clear all color html-tag for a string
|
|
||||||
- `IsConsole(w io.Writer)` Determine whether w is one of stderr, stdout, stdin
|
|
||||||
- `HexToRgb(hex string) (rgb []int)` Convert hex color string to RGB numbers
|
|
||||||
- `RgbToHex(rgb []int) string` Convert RGB to hex code
|
|
||||||
- 更多请查看文档 https://pkg.go.dev/github.com/gookit/color
|
|
||||||
|
|
||||||
## 使用color的项目
|
|
||||||
|
|
||||||
看看这些使用了 https://github.com/gookit/color 的项目:
|
|
||||||
|
|
||||||
- https://github.com/Delta456/box-cli-maker Make Highly Customized Boxes for your CLI
|
|
||||||
|
|
||||||
## Gookit 工具包
|
|
||||||
|
|
||||||
- [gookit/ini](https://github.com/gookit/ini) INI配置读取管理,支持多文件加载,数据覆盖合并, 解析ENV变量, 解析变量引用
|
|
||||||
- [gookit/rux](https://github.com/gookit/rux) Simple and fast request router for golang HTTP
|
|
||||||
- [gookit/gcli](https://github.com/gookit/gcli) Go的命令行应用,工具库,运行CLI命令,支持命令行色彩,用户交互,进度显示,数据格式化显示
|
|
||||||
- [gookit/slog](https://github.com/gookit/slog) 简洁易扩展的go日志库
|
|
||||||
- [gookit/event](https://github.com/gookit/event) Go实现的轻量级的事件管理、调度程序库, 支持设置监听器的优先级, 支持对一组事件进行监听
|
|
||||||
- [gookit/cache](https://github.com/gookit/cache) 通用的缓存使用包装库,通过包装各种常用的驱动,来提供统一的使用API
|
|
||||||
- [gookit/config](https://github.com/gookit/config) Go应用配置管理,支持多种格式(JSON, YAML, TOML, INI, HCL, ENV, Flags),多文件加载,远程文件加载,数据合并
|
|
||||||
- [gookit/color](https://github.com/gookit/color) CLI 控制台颜色渲染工具库, 拥有简洁的使用API,支持16色,256色,RGB色彩渲染输出
|
|
||||||
- [gookit/filter](https://github.com/gookit/filter) 提供对Golang数据的过滤,净化,转换
|
|
||||||
- [gookit/validate](https://github.com/gookit/validate) Go通用的数据验证与过滤库,使用简单,内置大部分常用验证、过滤器
|
|
||||||
- [gookit/goutil](https://github.com/gookit/goutil) Go 的一些工具函数,格式化,特殊处理,常用信息获取等
|
|
||||||
- 更多请查看 https://github.com/gookit
|
|
||||||
|
|
||||||
## 参考项目
|
|
||||||
|
|
||||||
- [inhere/console](https://github.com/inhere/php-console)
|
|
||||||
- [xo/terminfo](https://github.com/xo/terminfo)
|
|
||||||
- [beego/bee](https://github.com/beego/bee)
|
|
||||||
- [issue9/term](https://github.com/issue9/term)
|
|
||||||
- [ANSI转义序列](https://zh.wikipedia.org/wiki/ANSI转义序列)
|
|
||||||
- [Standard ANSI color map](https://conemu.github.io/en/AnsiEscapeCodes.html#Standard_ANSI_color_map)
|
|
||||||
- [Terminal Colors](https://gist.github.com/XVilka/8346728)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
238
vendor/github.com/gookit/color/color.go
generated
vendored
238
vendor/github.com/gookit/color/color.go
generated
vendored
@@ -1,238 +0,0 @@
|
|||||||
/*
|
|
||||||
Package color is Command line color library.
|
|
||||||
Support rich color rendering output, universal API method, compatible with Windows system
|
|
||||||
|
|
||||||
Source code and other details for the project are available at GitHub:
|
|
||||||
|
|
||||||
https://github.com/gookit/color
|
|
||||||
|
|
||||||
More usage please see README and tests.
|
|
||||||
*/
|
|
||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"github.com/xo/terminfo"
|
|
||||||
)
|
|
||||||
|
|
||||||
// terminal color available level alias of the terminfo.ColorLevel*
|
|
||||||
const (
|
|
||||||
LevelNo = terminfo.ColorLevelNone // not support color.
|
|
||||||
Level16 = terminfo.ColorLevelBasic // 3/4 bit color supported
|
|
||||||
Level256 = terminfo.ColorLevelHundreds // 8 bit color supported
|
|
||||||
LevelRgb = terminfo.ColorLevelMillions // (24 bit)true color supported
|
|
||||||
)
|
|
||||||
|
|
||||||
// color render templates
|
|
||||||
// ESC 操作的表示:
|
|
||||||
// "\033"(Octal 8进制) = "\x1b"(Hexadecimal 16进制) = 27 (10进制)
|
|
||||||
const (
|
|
||||||
SettingTpl = "\x1b[%sm"
|
|
||||||
FullColorTpl = "\x1b[%sm%s\x1b[0m"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ResetSet Close all properties.
|
|
||||||
const ResetSet = "\x1b[0m"
|
|
||||||
|
|
||||||
// CodeExpr regex to clear color codes eg "\033[1;36mText\x1b[0m"
|
|
||||||
const CodeExpr = `\033\[[\d;?]+m`
|
|
||||||
|
|
||||||
var (
|
|
||||||
// Enable switch color render and display
|
|
||||||
//
|
|
||||||
// NOTICE:
|
|
||||||
// if ENV: NO_COLOR is not empty, will disable color render.
|
|
||||||
Enable = os.Getenv("NO_COLOR") == ""
|
|
||||||
// RenderTag render HTML tag on call color.Xprint, color.PrintX
|
|
||||||
RenderTag = true
|
|
||||||
// debug mode for development.
|
|
||||||
//
|
|
||||||
// set env:
|
|
||||||
// COLOR_DEBUG_MODE=on
|
|
||||||
// or:
|
|
||||||
// COLOR_DEBUG_MODE=on go run ./_examples/envcheck.go
|
|
||||||
debugMode = os.Getenv("COLOR_DEBUG_MODE") == "on"
|
|
||||||
// inner errors record on detect color level
|
|
||||||
innerErrs []error
|
|
||||||
// output the default io.Writer message print
|
|
||||||
output io.Writer = os.Stdout
|
|
||||||
// mark current env, It's like in `cmd.exe`
|
|
||||||
// if not in windows, it's always is False.
|
|
||||||
isLikeInCmd bool
|
|
||||||
// the color support level for current terminal
|
|
||||||
// needVTP - need enable VTP, only for windows OS
|
|
||||||
colorLevel, needVTP = detectTermColorLevel()
|
|
||||||
// match color codes
|
|
||||||
codeRegex = regexp.MustCompile(CodeExpr)
|
|
||||||
// mark current env is support color.
|
|
||||||
// Always: isLikeInCmd != supportColor
|
|
||||||
// supportColor = IsSupportColor()
|
|
||||||
)
|
|
||||||
|
|
||||||
// TermColorLevel value on current ENV
|
|
||||||
func TermColorLevel() terminfo.ColorLevel {
|
|
||||||
return colorLevel
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportColor on the current ENV
|
|
||||||
func SupportColor() bool {
|
|
||||||
return colorLevel > terminfo.ColorLevelNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// Support16Color on the current ENV
|
|
||||||
// func Support16Color() bool {
|
|
||||||
// return colorLevel > terminfo.ColorLevelNone
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Support256Color on the current ENV
|
|
||||||
func Support256Color() bool {
|
|
||||||
return colorLevel > terminfo.ColorLevelBasic
|
|
||||||
}
|
|
||||||
|
|
||||||
// SupportTrueColor on the current ENV
|
|
||||||
func SupportTrueColor() bool {
|
|
||||||
return colorLevel > terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* global settings
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Set set console color attributes
|
|
||||||
func Set(colors ...Color) (int, error) {
|
|
||||||
code := Colors2code(colors...)
|
|
||||||
err := SetTerminal(code)
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset reset console color attributes
|
|
||||||
func Reset() (int, error) {
|
|
||||||
err := ResetTerminal()
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Disable disable color output
|
|
||||||
func Disable() bool {
|
|
||||||
oldVal := Enable
|
|
||||||
Enable = false
|
|
||||||
return oldVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// NotRenderTag on call color.Xprint, color.PrintX
|
|
||||||
func NotRenderTag() {
|
|
||||||
RenderTag = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOutput set default colored text output
|
|
||||||
func SetOutput(w io.Writer) {
|
|
||||||
output = w
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetOutput reset output
|
|
||||||
func ResetOutput() {
|
|
||||||
output = os.Stdout
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetOptions reset all package option setting
|
|
||||||
func ResetOptions() {
|
|
||||||
RenderTag = true
|
|
||||||
Enable = true
|
|
||||||
output = os.Stdout
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForceColor force open color render
|
|
||||||
func ForceSetColorLevel(level terminfo.ColorLevel) terminfo.ColorLevel {
|
|
||||||
oldLevelVal := colorLevel
|
|
||||||
colorLevel = level
|
|
||||||
return oldLevelVal
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForceColor force open color render
|
|
||||||
func ForceColor() terminfo.ColorLevel {
|
|
||||||
return ForceOpenColor()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForceOpenColor force open color render
|
|
||||||
func ForceOpenColor() terminfo.ColorLevel {
|
|
||||||
// TODO should set level to ?
|
|
||||||
return ForceSetColorLevel(terminfo.ColorLevelMillions)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsLikeInCmd check result
|
|
||||||
// Deprecated
|
|
||||||
func IsLikeInCmd() bool {
|
|
||||||
return isLikeInCmd
|
|
||||||
}
|
|
||||||
|
|
||||||
// InnerErrs info
|
|
||||||
func InnerErrs() []error {
|
|
||||||
return innerErrs
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* render color code
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// RenderCode render message by color code.
|
|
||||||
// Usage:
|
|
||||||
// msg := RenderCode("3;32;45", "some", "message")
|
|
||||||
func RenderCode(code string, args ...interface{}) string {
|
|
||||||
var message string
|
|
||||||
if ln := len(args); ln == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
message = fmt.Sprint(args...)
|
|
||||||
if len(code) == 0 {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
|
|
||||||
// disabled OR not support color
|
|
||||||
if !Enable || !SupportColor() {
|
|
||||||
return ClearCode(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(FullColorTpl, code, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderWithSpaces Render code with spaces.
|
|
||||||
// If the number of args is > 1, a space will be added between the args
|
|
||||||
func RenderWithSpaces(code string, args ...interface{}) string {
|
|
||||||
message := formatArgsForPrintln(args)
|
|
||||||
if len(code) == 0 {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
|
|
||||||
// disabled OR not support color
|
|
||||||
if !Enable || !SupportColor() {
|
|
||||||
return ClearCode(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(FullColorTpl, code, message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderString render a string with color code.
|
|
||||||
// Usage:
|
|
||||||
// msg := RenderString("3;32;45", "a message")
|
|
||||||
func RenderString(code string, str string) string {
|
|
||||||
if len(code) == 0 || str == "" {
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
// disabled OR not support color
|
|
||||||
if !Enable || !SupportColor() {
|
|
||||||
return ClearCode(str)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(FullColorTpl, code, str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearCode clear color codes.
|
|
||||||
// eg: "\033[36;1mText\x1b[0m" -> "Text"
|
|
||||||
func ClearCode(str string) string {
|
|
||||||
return codeRegex.ReplaceAllString(str, "")
|
|
||||||
}
|
|
||||||
440
vendor/github.com/gookit/color/color_16.go
generated
vendored
440
vendor/github.com/gookit/color/color_16.go
generated
vendored
@@ -1,440 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Color Color16, 16 color value type
|
|
||||||
// 3(2^3=8) OR 4(2^4=16) bite color.
|
|
||||||
type Color uint8
|
|
||||||
type Basic = Color // alias of Color
|
|
||||||
|
|
||||||
// Opts basic color options. code: 0 - 9
|
|
||||||
type Opts []Color
|
|
||||||
|
|
||||||
// Add option value
|
|
||||||
func (o *Opts) Add(ops ...Color) {
|
|
||||||
for _, op := range ops {
|
|
||||||
if uint8(op) < 10 {
|
|
||||||
*o = append(*o, op)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValid options
|
|
||||||
func (o Opts) IsValid() bool {
|
|
||||||
return len(o) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty options
|
|
||||||
func (o Opts) IsEmpty() bool {
|
|
||||||
return len(o) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// String options to string. eg: "1;3"
|
|
||||||
func (o Opts) String() string {
|
|
||||||
return Colors2code(o...)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* Basic 16 color definition
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Base value for foreground/background color
|
|
||||||
const (
|
|
||||||
FgBase uint8 = 30
|
|
||||||
BgBase uint8 = 40
|
|
||||||
// hi color base code
|
|
||||||
HiFgBase uint8 = 90
|
|
||||||
HiBgBase uint8 = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
// Foreground colors. basic foreground colors 30 - 37
|
|
||||||
const (
|
|
||||||
FgBlack Color = iota + 30
|
|
||||||
FgRed
|
|
||||||
FgGreen
|
|
||||||
FgYellow
|
|
||||||
FgBlue
|
|
||||||
FgMagenta // 品红
|
|
||||||
FgCyan // 青色
|
|
||||||
FgWhite
|
|
||||||
// FgDefault revert default FG
|
|
||||||
FgDefault Color = 39
|
|
||||||
)
|
|
||||||
|
|
||||||
// Extra foreground color 90 - 97(非标准)
|
|
||||||
const (
|
|
||||||
FgDarkGray Color = iota + 90 // 亮黑(灰)
|
|
||||||
FgLightRed
|
|
||||||
FgLightGreen
|
|
||||||
FgLightYellow
|
|
||||||
FgLightBlue
|
|
||||||
FgLightMagenta
|
|
||||||
FgLightCyan
|
|
||||||
FgLightWhite
|
|
||||||
// FgGray is alias of FgDarkGray
|
|
||||||
FgGray Color = 90 // 亮黑(灰)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Background colors. basic background colors 40 - 47
|
|
||||||
const (
|
|
||||||
BgBlack Color = iota + 40
|
|
||||||
BgRed
|
|
||||||
BgGreen
|
|
||||||
BgYellow // BgBrown like yellow
|
|
||||||
BgBlue
|
|
||||||
BgMagenta
|
|
||||||
BgCyan
|
|
||||||
BgWhite
|
|
||||||
// BgDefault revert default BG
|
|
||||||
BgDefault Color = 49
|
|
||||||
)
|
|
||||||
|
|
||||||
// Extra background color 100 - 107(非标准)
|
|
||||||
const (
|
|
||||||
BgDarkGray Color = iota + 100
|
|
||||||
BgLightRed
|
|
||||||
BgLightGreen
|
|
||||||
BgLightYellow
|
|
||||||
BgLightBlue
|
|
||||||
BgLightMagenta
|
|
||||||
BgLightCyan
|
|
||||||
BgLightWhite
|
|
||||||
// BgGray is alias of BgDarkGray
|
|
||||||
BgGray Color = 100
|
|
||||||
)
|
|
||||||
|
|
||||||
// Option settings
|
|
||||||
const (
|
|
||||||
OpReset Color = iota // 0 重置所有设置
|
|
||||||
OpBold // 1 加粗
|
|
||||||
OpFuzzy // 2 模糊(不是所有的终端仿真器都支持)
|
|
||||||
OpItalic // 3 斜体(不是所有的终端仿真器都支持)
|
|
||||||
OpUnderscore // 4 下划线
|
|
||||||
OpBlink // 5 闪烁
|
|
||||||
OpFastBlink // 5 快速闪烁(未广泛支持)
|
|
||||||
OpReverse // 7 颠倒的 交换背景色与前景色
|
|
||||||
OpConcealed // 8 隐匿的
|
|
||||||
OpStrikethrough // 9 删除的,删除线(未广泛支持)
|
|
||||||
)
|
|
||||||
|
|
||||||
// There are basic and light foreground color aliases
|
|
||||||
const (
|
|
||||||
Red = FgRed
|
|
||||||
Cyan = FgCyan
|
|
||||||
Gray = FgDarkGray // is light Black
|
|
||||||
Blue = FgBlue
|
|
||||||
Black = FgBlack
|
|
||||||
Green = FgGreen
|
|
||||||
White = FgWhite
|
|
||||||
Yellow = FgYellow
|
|
||||||
Magenta = FgMagenta
|
|
||||||
|
|
||||||
// special
|
|
||||||
|
|
||||||
Bold = OpBold
|
|
||||||
Normal = FgDefault
|
|
||||||
|
|
||||||
// extra light
|
|
||||||
|
|
||||||
LightRed = FgLightRed
|
|
||||||
LightCyan = FgLightCyan
|
|
||||||
LightBlue = FgLightBlue
|
|
||||||
LightGreen = FgLightGreen
|
|
||||||
LightWhite = FgLightWhite
|
|
||||||
LightYellow = FgLightYellow
|
|
||||||
LightMagenta = FgLightMagenta
|
|
||||||
|
|
||||||
HiRed = FgLightRed
|
|
||||||
HiCyan = FgLightCyan
|
|
||||||
HiBlue = FgLightBlue
|
|
||||||
HiGreen = FgLightGreen
|
|
||||||
HiWhite = FgLightWhite
|
|
||||||
HiYellow = FgLightYellow
|
|
||||||
HiMagenta = FgLightMagenta
|
|
||||||
|
|
||||||
BgHiRed = BgLightRed
|
|
||||||
BgHiCyan = BgLightCyan
|
|
||||||
BgHiBlue = BgLightBlue
|
|
||||||
BgHiGreen = BgLightGreen
|
|
||||||
BgHiWhite = BgLightWhite
|
|
||||||
BgHiYellow = BgLightYellow
|
|
||||||
BgHiMagenta = BgLightMagenta
|
|
||||||
)
|
|
||||||
|
|
||||||
// Bit4 an method for create Color
|
|
||||||
func Bit4(code uint8) Color {
|
|
||||||
return Color(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* Color render methods
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Name get color code name.
|
|
||||||
func (c Color) Name() string {
|
|
||||||
name, ok := basic2nameMap[uint8(c)]
|
|
||||||
if ok {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text render a text message
|
|
||||||
func (c Color) Text(message string) string {
|
|
||||||
return RenderString(c.String(), message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render messages by color setting
|
|
||||||
// Usage:
|
|
||||||
// green := color.FgGreen.Render
|
|
||||||
// fmt.Println(green("message"))
|
|
||||||
func (c Color) Render(a ...interface{}) string {
|
|
||||||
return RenderCode(c.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renderln messages by color setting.
|
|
||||||
// like Println, will add spaces for each argument
|
|
||||||
// Usage:
|
|
||||||
// green := color.FgGreen.Renderln
|
|
||||||
// fmt.Println(green("message"))
|
|
||||||
func (c Color) Renderln(a ...interface{}) string {
|
|
||||||
return RenderWithSpaces(c.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint render messages by color setting. is alias of the Render()
|
|
||||||
func (c Color) Sprint(a ...interface{}) string {
|
|
||||||
return RenderCode(c.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf format and render message.
|
|
||||||
// Usage:
|
|
||||||
// green := color.Green.Sprintf
|
|
||||||
// colored := green("message")
|
|
||||||
func (c Color) Sprintf(format string, args ...interface{}) string {
|
|
||||||
return RenderString(c.String(), fmt.Sprintf(format, args...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print messages.
|
|
||||||
// Usage:
|
|
||||||
// color.Green.Print("message")
|
|
||||||
// OR:
|
|
||||||
// green := color.FgGreen.Print
|
|
||||||
// green("message")
|
|
||||||
func (c Color) Print(args ...interface{}) {
|
|
||||||
doPrintV2(c.Code(), fmt.Sprint(args...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and print messages.
|
|
||||||
// Usage:
|
|
||||||
// color.Cyan.Printf("string %s", "arg0")
|
|
||||||
func (c Color) Printf(format string, a ...interface{}) {
|
|
||||||
doPrintV2(c.Code(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println messages with new line
|
|
||||||
func (c Color) Println(a ...interface{}) {
|
|
||||||
doPrintlnV2(c.String(), a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Light current color. eg: 36(FgCyan) -> 96(FgLightCyan).
|
|
||||||
// Usage:
|
|
||||||
// lightCyan := Cyan.Light()
|
|
||||||
// lightCyan.Print("message")
|
|
||||||
func (c Color) Light() Color {
|
|
||||||
val := int(c)
|
|
||||||
if val >= 30 && val <= 47 {
|
|
||||||
return Color(uint8(c) + 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't change
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// Darken current color. eg. 96(FgLightCyan) -> 36(FgCyan)
|
|
||||||
// Usage:
|
|
||||||
// cyan := LightCyan.Darken()
|
|
||||||
// cyan.Print("message")
|
|
||||||
func (c Color) Darken() Color {
|
|
||||||
val := int(c)
|
|
||||||
if val >= 90 && val <= 107 {
|
|
||||||
return Color(uint8(c) - 60)
|
|
||||||
}
|
|
||||||
|
|
||||||
// don't change
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// C256 convert 16 color to 256-color code.
|
|
||||||
func (c Color) C256() Color256 {
|
|
||||||
val := uint8(c)
|
|
||||||
if val < 10 { // is option code
|
|
||||||
return emptyC256 // empty
|
|
||||||
}
|
|
||||||
|
|
||||||
var isBg uint8
|
|
||||||
if val >= BgBase && val <= 47 { // is bg
|
|
||||||
isBg = AsBg
|
|
||||||
val = val - 10 // to fg code
|
|
||||||
} else if val >= HiBgBase && val <= 107 { // is hi bg
|
|
||||||
isBg = AsBg
|
|
||||||
val = val - 10 // to fg code
|
|
||||||
}
|
|
||||||
|
|
||||||
if c256, ok := basicTo256Map[val]; ok {
|
|
||||||
return Color256{c256, isBg}
|
|
||||||
}
|
|
||||||
|
|
||||||
// use raw value direct convert
|
|
||||||
return Color256{val}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RGB convert 16 color to 256-color code.
|
|
||||||
func (c Color) RGB() RGBColor {
|
|
||||||
val := uint8(c)
|
|
||||||
if val < 10 { // is option code
|
|
||||||
return emptyRGBColor
|
|
||||||
}
|
|
||||||
|
|
||||||
return HEX(Basic2hex(val))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code convert to code string. eg "35"
|
|
||||||
func (c Color) Code() string {
|
|
||||||
// return fmt.Sprintf("%d", c)
|
|
||||||
return strconv.Itoa(int(c))
|
|
||||||
}
|
|
||||||
|
|
||||||
// String convert to code string. eg "35"
|
|
||||||
func (c Color) String() string {
|
|
||||||
// return fmt.Sprintf("%d", c)
|
|
||||||
return strconv.Itoa(int(c))
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValid color value
|
|
||||||
func (c Color) IsValid() bool {
|
|
||||||
return c < 107
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* basic color maps
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// FgColors foreground colors map
|
|
||||||
var FgColors = map[string]Color{
|
|
||||||
"black": FgBlack,
|
|
||||||
"red": FgRed,
|
|
||||||
"green": FgGreen,
|
|
||||||
"yellow": FgYellow,
|
|
||||||
"blue": FgBlue,
|
|
||||||
"magenta": FgMagenta,
|
|
||||||
"cyan": FgCyan,
|
|
||||||
"white": FgWhite,
|
|
||||||
"default": FgDefault,
|
|
||||||
}
|
|
||||||
|
|
||||||
// BgColors background colors map
|
|
||||||
var BgColors = map[string]Color{
|
|
||||||
"black": BgBlack,
|
|
||||||
"red": BgRed,
|
|
||||||
"green": BgGreen,
|
|
||||||
"yellow": BgYellow,
|
|
||||||
"blue": BgBlue,
|
|
||||||
"magenta": BgMagenta,
|
|
||||||
"cyan": BgCyan,
|
|
||||||
"white": BgWhite,
|
|
||||||
"default": BgDefault,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExFgColors extra foreground colors map
|
|
||||||
var ExFgColors = map[string]Color{
|
|
||||||
"darkGray": FgDarkGray,
|
|
||||||
"lightRed": FgLightRed,
|
|
||||||
"lightGreen": FgLightGreen,
|
|
||||||
"lightYellow": FgLightYellow,
|
|
||||||
"lightBlue": FgLightBlue,
|
|
||||||
"lightMagenta": FgLightMagenta,
|
|
||||||
"lightCyan": FgLightCyan,
|
|
||||||
"lightWhite": FgLightWhite,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExBgColors extra background colors map
|
|
||||||
var ExBgColors = map[string]Color{
|
|
||||||
"darkGray": BgDarkGray,
|
|
||||||
"lightRed": BgLightRed,
|
|
||||||
"lightGreen": BgLightGreen,
|
|
||||||
"lightYellow": BgLightYellow,
|
|
||||||
"lightBlue": BgLightBlue,
|
|
||||||
"lightMagenta": BgLightMagenta,
|
|
||||||
"lightCyan": BgLightCyan,
|
|
||||||
"lightWhite": BgLightWhite,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options color options map
|
|
||||||
// Deprecated
|
|
||||||
// NOTICE: please use AllOptions instead.
|
|
||||||
var Options = AllOptions
|
|
||||||
|
|
||||||
// AllOptions color options map
|
|
||||||
var AllOptions = map[string]Color{
|
|
||||||
"reset": OpReset,
|
|
||||||
"bold": OpBold,
|
|
||||||
"fuzzy": OpFuzzy,
|
|
||||||
"italic": OpItalic,
|
|
||||||
"underscore": OpUnderscore,
|
|
||||||
"blink": OpBlink,
|
|
||||||
"reverse": OpReverse,
|
|
||||||
"concealed": OpConcealed,
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
// TODO basic name alias
|
|
||||||
// basicNameAlias = map[string]string{}
|
|
||||||
|
|
||||||
// basic color name to code
|
|
||||||
name2basicMap = initName2basicMap()
|
|
||||||
// basic2nameMap basic color code to name
|
|
||||||
basic2nameMap = map[uint8]string{
|
|
||||||
30: "black",
|
|
||||||
31: "red",
|
|
||||||
32: "green",
|
|
||||||
33: "yellow",
|
|
||||||
34: "blue",
|
|
||||||
35: "magenta",
|
|
||||||
36: "cyan",
|
|
||||||
37: "white",
|
|
||||||
// hi color code
|
|
||||||
90: "lightBlack",
|
|
||||||
91: "lightRed",
|
|
||||||
92: "lightGreen",
|
|
||||||
93: "lightYellow",
|
|
||||||
94: "lightBlue",
|
|
||||||
95: "lightMagenta",
|
|
||||||
96: "lightCyan",
|
|
||||||
97: "lightWhite",
|
|
||||||
// options
|
|
||||||
0: "reset",
|
|
||||||
1: "bold",
|
|
||||||
2: "fuzzy",
|
|
||||||
3: "italic",
|
|
||||||
4: "underscore",
|
|
||||||
5: "blink",
|
|
||||||
7: "reverse",
|
|
||||||
8: "concealed",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Basic2nameMap data
|
|
||||||
func Basic2nameMap() map[uint8]string {
|
|
||||||
return basic2nameMap
|
|
||||||
}
|
|
||||||
|
|
||||||
func initName2basicMap() map[string]uint8 {
|
|
||||||
n2b := make(map[string]uint8, len(basic2nameMap))
|
|
||||||
for u, s := range basic2nameMap {
|
|
||||||
n2b[s] = u
|
|
||||||
}
|
|
||||||
return n2b
|
|
||||||
}
|
|
||||||
308
vendor/github.com/gookit/color/color_256.go
generated
vendored
308
vendor/github.com/gookit/color/color_256.go
generated
vendored
@@ -1,308 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
/*
|
|
||||||
from wikipedia, 256 color:
|
|
||||||
ESC[ … 38;5;<n> … m选择前景色
|
|
||||||
ESC[ … 48;5;<n> … m选择背景色
|
|
||||||
0- 7:标准颜色(同 ESC[30–37m)
|
|
||||||
8- 15:高强度颜色(同 ESC[90–97m)
|
|
||||||
16-231:6 × 6 × 6 立方(216色): 16 + 36 × r + 6 × g + b (0 ≤ r, g, b ≤ 5)
|
|
||||||
232-255:从黑到白的24阶灰度色
|
|
||||||
*/
|
|
||||||
|
|
||||||
// tpl for 8 bit 256 color(`2^8`)
|
|
||||||
//
|
|
||||||
// format:
|
|
||||||
// ESC[ … 38;5;<n> … m // 选择前景色
|
|
||||||
// ESC[ … 48;5;<n> … m // 选择背景色
|
|
||||||
//
|
|
||||||
// example:
|
|
||||||
// fg "\x1b[38;5;242m"
|
|
||||||
// bg "\x1b[48;5;208m"
|
|
||||||
// both "\x1b[38;5;242;48;5;208m"
|
|
||||||
//
|
|
||||||
// links:
|
|
||||||
// https://zh.wikipedia.org/wiki/ANSI%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97#8位
|
|
||||||
const (
|
|
||||||
TplFg256 = "38;5;%d"
|
|
||||||
TplBg256 = "48;5;%d"
|
|
||||||
Fg256Pfx = "38;5;"
|
|
||||||
Bg256Pfx = "48;5;"
|
|
||||||
)
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* 8bit(256) Color: Bit8Color Color256
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Color256 256 color (8 bit), uint8 range at 0 - 255
|
|
||||||
//
|
|
||||||
// 颜色值使用10进制和16进制都可 0x98 = 152
|
|
||||||
//
|
|
||||||
// The color consists of two uint8:
|
|
||||||
// 0: color value
|
|
||||||
// 1: color type; Fg=0, Bg=1, >1: unset value
|
|
||||||
//
|
|
||||||
// example:
|
|
||||||
// fg color: [152, 0]
|
|
||||||
// bg color: [152, 1]
|
|
||||||
//
|
|
||||||
// NOTICE: now support 256 color on windows CMD, PowerShell
|
|
||||||
// lint warn - Name starts with package name
|
|
||||||
type Color256 [2]uint8
|
|
||||||
type Bit8Color = Color256 // alias
|
|
||||||
|
|
||||||
var emptyC256 = Color256{1: 99}
|
|
||||||
|
|
||||||
// Bit8 create a color256
|
|
||||||
func Bit8(val uint8, isBg ...bool) Color256 {
|
|
||||||
return C256(val, isBg...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// C256 create a color256
|
|
||||||
func C256(val uint8, isBg ...bool) Color256 {
|
|
||||||
bc := Color256{val}
|
|
||||||
|
|
||||||
// mark is bg color
|
|
||||||
if len(isBg) > 0 && isBg[0] {
|
|
||||||
bc[1] = AsBg
|
|
||||||
}
|
|
||||||
|
|
||||||
return bc
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set terminal by 256 color code
|
|
||||||
func (c Color256) Set() error {
|
|
||||||
return SetTerminal(c.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset terminal. alias of the ResetTerminal()
|
|
||||||
func (c Color256) Reset() error {
|
|
||||||
return ResetTerminal()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print print message
|
|
||||||
func (c Color256) Print(a ...interface{}) {
|
|
||||||
doPrintV2(c.String(), fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and print message
|
|
||||||
func (c Color256) Printf(format string, a ...interface{}) {
|
|
||||||
doPrintV2(c.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println print message with newline
|
|
||||||
func (c Color256) Println(a ...interface{}) {
|
|
||||||
doPrintlnV2(c.String(), a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint returns rendered message
|
|
||||||
func (c Color256) Sprint(a ...interface{}) string {
|
|
||||||
return RenderCode(c.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf returns format and rendered message
|
|
||||||
func (c Color256) Sprintf(format string, a ...interface{}) string {
|
|
||||||
return RenderString(c.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// C16 convert color-256 to 16 color.
|
|
||||||
func (c Color256) C16() Color {
|
|
||||||
return c.Basic()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic convert color-256 to basic 16 color.
|
|
||||||
func (c Color256) Basic() Color {
|
|
||||||
return Color(c[0]) // TODO
|
|
||||||
}
|
|
||||||
|
|
||||||
// RGB convert color-256 to RGB color.
|
|
||||||
func (c Color256) RGB() RGBColor {
|
|
||||||
return RGBFromSlice(C256ToRgb(c[0]), c[1] == AsBg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RGBColor convert color-256 to RGB color.
|
|
||||||
func (c Color256) RGBColor() RGBColor {
|
|
||||||
return c.RGB()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Value return color value
|
|
||||||
func (c Color256) Value() uint8 {
|
|
||||||
return c[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code convert to color code string. eg: "12"
|
|
||||||
func (c Color256) Code() string {
|
|
||||||
return strconv.Itoa(int(c[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullCode convert to color code string with prefix. eg: "38;5;12"
|
|
||||||
func (c Color256) FullCode() string {
|
|
||||||
return c.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// String convert to color code string with prefix. eg: "38;5;12"
|
|
||||||
func (c Color256) String() string {
|
|
||||||
if c[1] == AsFg { // 0 is Fg
|
|
||||||
// return fmt.Sprintf(TplFg256, c[0])
|
|
||||||
return Fg256Pfx + strconv.Itoa(int(c[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if c[1] == AsBg { // 1 is Bg
|
|
||||||
// return fmt.Sprintf(TplBg256, c[0])
|
|
||||||
return Bg256Pfx + strconv.Itoa(int(c[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
return "" // empty
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsFg color
|
|
||||||
func (c Color256) IsFg() bool {
|
|
||||||
return c[1] == AsFg
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToFg 256 color
|
|
||||||
func (c Color256) ToFg() Color256 {
|
|
||||||
c[1] = AsFg
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsBg color
|
|
||||||
func (c Color256) IsBg() bool {
|
|
||||||
return c[1] == AsBg
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToBg 256 color
|
|
||||||
func (c Color256) ToBg() Color256 {
|
|
||||||
c[1] = AsBg
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty value
|
|
||||||
func (c Color256) IsEmpty() bool {
|
|
||||||
return c[1] > 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* 8bit(256) Style
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Style256 definition
|
|
||||||
//
|
|
||||||
// 前/背景色
|
|
||||||
// 都是由两位uint8组成, 第一位是色彩值;
|
|
||||||
// 第二位与 Bit8Color 不一样的是,在这里表示是否设置了值 0 未设置 !=0 已设置
|
|
||||||
type Style256 struct {
|
|
||||||
// p Printer
|
|
||||||
|
|
||||||
// Name of the style
|
|
||||||
Name string
|
|
||||||
// color options of the style
|
|
||||||
opts Opts
|
|
||||||
// fg and bg color
|
|
||||||
fg, bg Color256
|
|
||||||
}
|
|
||||||
|
|
||||||
// S256 create a color256 style
|
|
||||||
// Usage:
|
|
||||||
// s := color.S256()
|
|
||||||
// s := color.S256(132) // fg
|
|
||||||
// s := color.S256(132, 203) // fg and bg
|
|
||||||
func S256(fgAndBg ...uint8) *Style256 {
|
|
||||||
s := &Style256{}
|
|
||||||
vl := len(fgAndBg)
|
|
||||||
if vl > 0 { // with fg
|
|
||||||
s.fg = Color256{fgAndBg[0], 1}
|
|
||||||
|
|
||||||
if vl > 1 { // and with bg
|
|
||||||
s.bg = Color256{fgAndBg[1], 1}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set fg and bg color value, can also with color options
|
|
||||||
func (s *Style256) Set(fgVal, bgVal uint8, opts ...Color) *Style256 {
|
|
||||||
s.fg = Color256{fgVal, 1}
|
|
||||||
s.bg = Color256{bgVal, 1}
|
|
||||||
s.opts.Add(opts...)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBg set bg color value
|
|
||||||
func (s *Style256) SetBg(bgVal uint8) *Style256 {
|
|
||||||
s.bg = Color256{bgVal, 1}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFg set fg color value
|
|
||||||
func (s *Style256) SetFg(fgVal uint8) *Style256 {
|
|
||||||
s.fg = Color256{fgVal, 1}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOpts set options
|
|
||||||
func (s *Style256) SetOpts(opts Opts) *Style256 {
|
|
||||||
s.opts = opts
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddOpts add options
|
|
||||||
func (s *Style256) AddOpts(opts ...Color) *Style256 {
|
|
||||||
s.opts.Add(opts...)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print message
|
|
||||||
func (s *Style256) Print(a ...interface{}) {
|
|
||||||
doPrintV2(s.String(), fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and print message
|
|
||||||
func (s *Style256) Printf(format string, a ...interface{}) {
|
|
||||||
doPrintV2(s.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println print message with newline
|
|
||||||
func (s *Style256) Println(a ...interface{}) {
|
|
||||||
doPrintlnV2(s.String(), a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint returns rendered message
|
|
||||||
func (s *Style256) Sprint(a ...interface{}) string {
|
|
||||||
return RenderCode(s.Code(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf returns format and rendered message
|
|
||||||
func (s *Style256) Sprintf(format string, a ...interface{}) string {
|
|
||||||
return RenderString(s.Code(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code convert to color code string
|
|
||||||
func (s *Style256) Code() string {
|
|
||||||
return s.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// String convert to color code string
|
|
||||||
func (s *Style256) String() string {
|
|
||||||
var ss []string
|
|
||||||
if s.fg[1] > 0 {
|
|
||||||
ss = append(ss, fmt.Sprintf(TplFg256, s.fg[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.bg[1] > 0 {
|
|
||||||
ss = append(ss, fmt.Sprintf(TplBg256, s.bg[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.opts.IsValid() {
|
|
||||||
ss = append(ss, s.opts.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(ss, ";")
|
|
||||||
}
|
|
||||||
391
vendor/github.com/gookit/color/color_rgb.go
generated
vendored
391
vendor/github.com/gookit/color/color_rgb.go
generated
vendored
@@ -1,391 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 24 bit RGB color
|
|
||||||
// RGB:
|
|
||||||
// R 0-255 G 0-255 B 0-255
|
|
||||||
// R 00-FF G 00-FF B 00-FF (16进制)
|
|
||||||
//
|
|
||||||
// Format:
|
|
||||||
// ESC[ … 38;2;<r>;<g>;<b> … m // Select RGB foreground color
|
|
||||||
// ESC[ … 48;2;<r>;<g>;<b> … m // Choose RGB background color
|
|
||||||
//
|
|
||||||
// links:
|
|
||||||
// https://zh.wikipedia.org/wiki/ANSI%E8%BD%AC%E4%B9%89%E5%BA%8F%E5%88%97#24位
|
|
||||||
//
|
|
||||||
// example:
|
|
||||||
// fg: \x1b[38;2;30;144;255mMESSAGE\x1b[0m
|
|
||||||
// bg: \x1b[48;2;30;144;255mMESSAGE\x1b[0m
|
|
||||||
// both: \x1b[38;2;233;90;203;48;2;30;144;255mMESSAGE\x1b[0m
|
|
||||||
const (
|
|
||||||
TplFgRGB = "38;2;%d;%d;%d"
|
|
||||||
TplBgRGB = "48;2;%d;%d;%d"
|
|
||||||
FgRGBPfx = "38;2;"
|
|
||||||
BgRGBPfx = "48;2;"
|
|
||||||
)
|
|
||||||
|
|
||||||
// mark color is fg or bg.
|
|
||||||
const (
|
|
||||||
AsFg uint8 = iota
|
|
||||||
AsBg
|
|
||||||
)
|
|
||||||
|
|
||||||
// values from https://github.com/go-terminfo/terminfo
|
|
||||||
// var (
|
|
||||||
// RgbaBlack = image_color.RGBA{0, 0, 0, 255}
|
|
||||||
// Red = color.RGBA{205, 0, 0, 255}
|
|
||||||
// Green = color.RGBA{0, 205, 0, 255}
|
|
||||||
// Orange = color.RGBA{205, 205, 0, 255}
|
|
||||||
// Blue = color.RGBA{0, 0, 238, 255}
|
|
||||||
// Magenta = color.RGBA{205, 0, 205, 255}
|
|
||||||
// Cyan = color.RGBA{0, 205, 205, 255}
|
|
||||||
// LightGrey = color.RGBA{229, 229, 229, 255}
|
|
||||||
//
|
|
||||||
// DarkGrey = color.RGBA{127, 127, 127, 255}
|
|
||||||
// LightRed = color.RGBA{255, 0, 0, 255}
|
|
||||||
// LightGreen = color.RGBA{0, 255, 0, 255}
|
|
||||||
// Yellow = color.RGBA{255, 255, 0, 255}
|
|
||||||
// LightBlue = color.RGBA{92, 92, 255, 255}
|
|
||||||
// LightMagenta = color.RGBA{255, 0, 255, 255}
|
|
||||||
// LightCyan = color.RGBA{0, 255, 255, 255}
|
|
||||||
// White = color.RGBA{255, 255, 255, 255}
|
|
||||||
// )
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* RGB Color(Bit24Color, TrueColor)
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// RGBColor definition.
|
|
||||||
//
|
|
||||||
// The first to third digits represent the color value.
|
|
||||||
// The last digit represents the foreground(0), background(1), >1 is unset value
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// // 0, 1, 2 is R,G,B.
|
|
||||||
// // 3rd: Fg=0, Bg=1, >1: unset value
|
|
||||||
// RGBColor{30,144,255, 0}
|
|
||||||
// RGBColor{30,144,255, 1}
|
|
||||||
//
|
|
||||||
// NOTICE: now support RGB color on windows CMD, PowerShell
|
|
||||||
type RGBColor [4]uint8
|
|
||||||
|
|
||||||
// create a empty RGBColor
|
|
||||||
var emptyRGBColor = RGBColor{3: 99}
|
|
||||||
|
|
||||||
// RGB color create.
|
|
||||||
// Usage:
|
|
||||||
// c := RGB(30,144,255)
|
|
||||||
// c := RGB(30,144,255, true)
|
|
||||||
// c.Print("message")
|
|
||||||
func RGB(r, g, b uint8, isBg ...bool) RGBColor {
|
|
||||||
rgb := RGBColor{r, g, b}
|
|
||||||
if len(isBg) > 0 && isBg[0] {
|
|
||||||
rgb[3] = AsBg
|
|
||||||
}
|
|
||||||
|
|
||||||
return rgb
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rgb alias of the RGB()
|
|
||||||
func Rgb(r, g, b uint8, isBg ...bool) RGBColor { return RGB(r, g, b, isBg...) }
|
|
||||||
|
|
||||||
// Bit24 alias of the RGB()
|
|
||||||
func Bit24(r, g, b uint8, isBg ...bool) RGBColor { return RGB(r, g, b, isBg...) }
|
|
||||||
|
|
||||||
// RGBFromSlice quick RGBColor from slice
|
|
||||||
func RGBFromSlice(rgb []uint8, isBg ...bool) RGBColor {
|
|
||||||
return RGB(rgb[0], rgb[1], rgb[2], isBg...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HEX create RGB color from a HEX color string.
|
|
||||||
// Usage:
|
|
||||||
// c := HEX("ccc") // rgb: [204 204 204]
|
|
||||||
// c := HEX("aabbcc") // rgb: [170 187 204]
|
|
||||||
// c := HEX("#aabbcc")
|
|
||||||
// c := HEX("0xaabbcc")
|
|
||||||
// c.Print("message")
|
|
||||||
func HEX(hex string, isBg ...bool) RGBColor {
|
|
||||||
if rgb := HexToRgb(hex); len(rgb) > 0 {
|
|
||||||
return RGB(uint8(rgb[0]), uint8(rgb[1]), uint8(rgb[2]), isBg...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mark is empty
|
|
||||||
return emptyRGBColor
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hex alias of the HEX()
|
|
||||||
func Hex(hex string, isBg ...bool) RGBColor { return HEX(hex, isBg...) }
|
|
||||||
|
|
||||||
// RGBFromString create RGB color from a string.
|
|
||||||
// Usage:
|
|
||||||
// c := RGBFromString("170,187,204")
|
|
||||||
// c.Print("message")
|
|
||||||
func RGBFromString(rgb string, isBg ...bool) RGBColor {
|
|
||||||
ss := stringToArr(rgb, ",")
|
|
||||||
if len(ss) != 3 {
|
|
||||||
return emptyRGBColor
|
|
||||||
}
|
|
||||||
|
|
||||||
var ar [3]int
|
|
||||||
for i, val := range ss {
|
|
||||||
iv, err := strconv.Atoi(val)
|
|
||||||
if err != nil {
|
|
||||||
return emptyRGBColor
|
|
||||||
}
|
|
||||||
|
|
||||||
ar[i] = iv
|
|
||||||
}
|
|
||||||
|
|
||||||
return RGB(uint8(ar[0]), uint8(ar[1]), uint8(ar[2]), isBg...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set terminal by rgb/true color code
|
|
||||||
func (c RGBColor) Set() error {
|
|
||||||
return SetTerminal(c.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset terminal. alias of the ResetTerminal()
|
|
||||||
func (c RGBColor) Reset() error {
|
|
||||||
return ResetTerminal()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print print message
|
|
||||||
func (c RGBColor) Print(a ...interface{}) {
|
|
||||||
doPrintV2(c.String(), fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and print message
|
|
||||||
func (c RGBColor) Printf(format string, a ...interface{}) {
|
|
||||||
doPrintV2(c.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println print message with newline
|
|
||||||
func (c RGBColor) Println(a ...interface{}) {
|
|
||||||
doPrintlnV2(c.String(), a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint returns rendered message
|
|
||||||
func (c RGBColor) Sprint(a ...interface{}) string {
|
|
||||||
return RenderCode(c.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf returns format and rendered message
|
|
||||||
func (c RGBColor) Sprintf(format string, a ...interface{}) string {
|
|
||||||
return RenderString(c.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Values to RGB values
|
|
||||||
func (c RGBColor) Values() []int {
|
|
||||||
return []int{int(c[0]), int(c[1]), int(c[2])}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code to color code string without prefix. eg: "204;123;56"
|
|
||||||
func (c RGBColor) Code() string {
|
|
||||||
return fmt.Sprintf("%d;%d;%d", c[0], c[1], c[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hex color rgb to hex string. as in "ff0080".
|
|
||||||
func (c RGBColor) Hex() string {
|
|
||||||
return fmt.Sprintf("%02x%02x%02x", c[0], c[1], c[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullCode to color code string with prefix
|
|
||||||
func (c RGBColor) FullCode() string {
|
|
||||||
return c.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// String to color code string with prefix. eg: "38;2;204;123;56"
|
|
||||||
func (c RGBColor) String() string {
|
|
||||||
if c[3] == AsFg {
|
|
||||||
return fmt.Sprintf(TplFgRGB, c[0], c[1], c[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
if c[3] == AsBg {
|
|
||||||
return fmt.Sprintf(TplBgRGB, c[0], c[1], c[2])
|
|
||||||
}
|
|
||||||
|
|
||||||
// c[3] > 1 is empty
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty value
|
|
||||||
func (c RGBColor) IsEmpty() bool {
|
|
||||||
return c[3] > AsBg
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsValid value
|
|
||||||
// func (c RGBColor) IsValid() bool {
|
|
||||||
// return c[3] <= AsBg
|
|
||||||
// }
|
|
||||||
|
|
||||||
// C256 returns the closest approximate 256 (8 bit) color
|
|
||||||
func (c RGBColor) C256() Color256 {
|
|
||||||
return C256(RgbTo256(c[0], c[1], c[2]), c[3] == AsBg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Basic returns the closest approximate 16 (4 bit) color
|
|
||||||
func (c RGBColor) Basic() Color {
|
|
||||||
// return Color(RgbToAnsi(c[0], c[1], c[2], c[3] == AsBg))
|
|
||||||
return Color(Rgb2basic(c[0], c[1], c[2], c[3] == AsBg))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Color returns the closest approximate 16 (4 bit) color
|
|
||||||
func (c RGBColor) Color() Color { return c.Basic() }
|
|
||||||
|
|
||||||
// C16 returns the closest approximate 16 (4 bit) color
|
|
||||||
func (c RGBColor) C16() Color { return c.Basic() }
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* RGB Style
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// RGBStyle definition.
|
|
||||||
//
|
|
||||||
// Foreground/Background color
|
|
||||||
// All are composed of 4 digits uint8, the first three digits are the color value;
|
|
||||||
// The last bit is different from RGBColor, here it indicates whether the value is set.
|
|
||||||
// - 1 Has been set
|
|
||||||
// - ^1 Not set
|
|
||||||
type RGBStyle struct {
|
|
||||||
// Name of the style
|
|
||||||
Name string
|
|
||||||
// color options of the style
|
|
||||||
opts Opts
|
|
||||||
// fg and bg color
|
|
||||||
fg, bg RGBColor
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRGBStyle create a RGBStyle.
|
|
||||||
func NewRGBStyle(fg RGBColor, bg ...RGBColor) *RGBStyle {
|
|
||||||
s := &RGBStyle{}
|
|
||||||
if len(bg) > 0 {
|
|
||||||
s.SetBg(bg[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.SetFg(fg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HEXStyle create a RGBStyle from HEX color string.
|
|
||||||
// Usage:
|
|
||||||
// s := HEXStyle("aabbcc", "eee")
|
|
||||||
// s.Print("message")
|
|
||||||
func HEXStyle(fg string, bg ...string) *RGBStyle {
|
|
||||||
s := &RGBStyle{}
|
|
||||||
if len(bg) > 0 {
|
|
||||||
s.SetBg(HEX(bg[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(fg) > 0 {
|
|
||||||
s.SetFg(HEX(fg))
|
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// RGBStyleFromString create a RGBStyle from color value string.
|
|
||||||
// Usage:
|
|
||||||
// s := RGBStyleFromString("170,187,204", "70,87,4")
|
|
||||||
// s.Print("message")
|
|
||||||
func RGBStyleFromString(fg string, bg ...string) *RGBStyle {
|
|
||||||
s := &RGBStyle{}
|
|
||||||
if len(bg) > 0 {
|
|
||||||
s.SetBg(RGBFromString(bg[0]))
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.SetFg(RGBFromString(fg))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set fg and bg color, can also with color options
|
|
||||||
func (s *RGBStyle) Set(fg, bg RGBColor, opts ...Color) *RGBStyle {
|
|
||||||
return s.SetFg(fg).SetBg(bg).SetOpts(opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFg set fg color
|
|
||||||
func (s *RGBStyle) SetFg(fg RGBColor) *RGBStyle {
|
|
||||||
fg[3] = 1 // add fixed value, mark is valid
|
|
||||||
s.fg = fg
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetBg set bg color
|
|
||||||
func (s *RGBStyle) SetBg(bg RGBColor) *RGBStyle {
|
|
||||||
bg[3] = 1 // add fixed value, mark is valid
|
|
||||||
s.bg = bg
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetOpts set color options
|
|
||||||
func (s *RGBStyle) SetOpts(opts Opts) *RGBStyle {
|
|
||||||
s.opts = opts
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddOpts add options
|
|
||||||
func (s *RGBStyle) AddOpts(opts ...Color) *RGBStyle {
|
|
||||||
s.opts.Add(opts...)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print print message
|
|
||||||
func (s *RGBStyle) Print(a ...interface{}) {
|
|
||||||
doPrintV2(s.String(), fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and print message
|
|
||||||
func (s *RGBStyle) Printf(format string, a ...interface{}) {
|
|
||||||
doPrintV2(s.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println print message with newline
|
|
||||||
func (s *RGBStyle) Println(a ...interface{}) {
|
|
||||||
doPrintlnV2(s.String(), a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint returns rendered message
|
|
||||||
func (s *RGBStyle) Sprint(a ...interface{}) string {
|
|
||||||
return RenderCode(s.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf returns format and rendered message
|
|
||||||
func (s *RGBStyle) Sprintf(format string, a ...interface{}) string {
|
|
||||||
return RenderString(s.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code convert to color code string
|
|
||||||
func (s *RGBStyle) Code() string {
|
|
||||||
return s.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// FullCode convert to color code string
|
|
||||||
func (s *RGBStyle) FullCode() string {
|
|
||||||
return s.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// String convert to color code string
|
|
||||||
func (s *RGBStyle) String() string {
|
|
||||||
var ss []string
|
|
||||||
// last value ensure is enable.
|
|
||||||
if s.fg[3] == 1 {
|
|
||||||
ss = append(ss, fmt.Sprintf(TplFgRGB, s.fg[0], s.fg[1], s.fg[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.bg[3] == 1 {
|
|
||||||
ss = append(ss, fmt.Sprintf(TplBgRGB, s.bg[0], s.bg[1], s.bg[2]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.opts.IsValid() {
|
|
||||||
ss = append(ss, s.opts.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(ss, ";")
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty style
|
|
||||||
func (s *RGBStyle) IsEmpty() bool {
|
|
||||||
return s.fg[3] != 1 && s.bg[3] != 1
|
|
||||||
}
|
|
||||||
427
vendor/github.com/gookit/color/color_tag.go
generated
vendored
427
vendor/github.com/gookit/color/color_tag.go
generated
vendored
@@ -1,427 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// output colored text like use html tag. (not support windows cmd)
|
|
||||||
const (
|
|
||||||
// MatchExpr regex to match color tags
|
|
||||||
//
|
|
||||||
// Notice: golang 不支持反向引用. 即不支持使用 \1 引用第一个匹配 ([a-z=;]+)
|
|
||||||
// MatchExpr = `<([a-z=;]+)>(.*?)<\/\1>`
|
|
||||||
// 所以调整一下 统一使用 `</>` 来结束标签,例如 "<info>some text</>"
|
|
||||||
//
|
|
||||||
// allow custom attrs, eg: "<fg=white;bg=blue;op=bold>content</>"
|
|
||||||
// (?s:...) s - 让 "." 匹配换行
|
|
||||||
MatchExpr = `<([0-9a-zA-Z_=,;]+)>(?s:(.*?))<\/>`
|
|
||||||
|
|
||||||
// AttrExpr regex to match custom color attributes
|
|
||||||
// eg: "<fg=white;bg=blue;op=bold>content</>"
|
|
||||||
AttrExpr = `(fg|bg|op)[\s]*=[\s]*([0-9a-zA-Z,]+);?`
|
|
||||||
|
|
||||||
// StripExpr regex used for removing color tags
|
|
||||||
// StripExpr = `<[\/]?[a-zA-Z=;]+>`
|
|
||||||
// 随着上面的做一些调整
|
|
||||||
StripExpr = `<[\/]?[0-9a-zA-Z_=,;]*>`
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
attrRegex = regexp.MustCompile(AttrExpr)
|
|
||||||
matchRegex = regexp.MustCompile(MatchExpr)
|
|
||||||
stripRegex = regexp.MustCompile(StripExpr)
|
|
||||||
)
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* internal defined color tags
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// There are internal defined color tags
|
|
||||||
// Usage: <tag>content text</>
|
|
||||||
// @notice 加 0 在前面是为了防止之前的影响到现在的设置
|
|
||||||
var colorTags = map[string]string{
|
|
||||||
// basic tags
|
|
||||||
"red": "0;31",
|
|
||||||
"red1": "1;31", // with bold
|
|
||||||
"redB": "1;31",
|
|
||||||
"red_b": "1;31",
|
|
||||||
"blue": "0;34",
|
|
||||||
"blue1": "1;34", // with bold
|
|
||||||
"blueB": "1;34",
|
|
||||||
"blue_b": "1;34",
|
|
||||||
"cyan": "0;36",
|
|
||||||
"cyan1": "1;36", // with bold
|
|
||||||
"cyanB": "1;36",
|
|
||||||
"cyan_b": "1;36",
|
|
||||||
"green": "0;32",
|
|
||||||
"green1": "1;32", // with bold
|
|
||||||
"greenB": "1;32",
|
|
||||||
"green_b": "1;32",
|
|
||||||
"black": "0;30",
|
|
||||||
"white": "1;37",
|
|
||||||
"default": "0;39", // no color
|
|
||||||
"normal": "0;39", // no color
|
|
||||||
"brown": "0;33", // #A52A2A
|
|
||||||
"yellow": "0;33",
|
|
||||||
"ylw0": "0;33",
|
|
||||||
"yellowB": "1;33", // with bold
|
|
||||||
"ylw1": "1;33",
|
|
||||||
"ylwB": "1;33",
|
|
||||||
"magenta": "0;35",
|
|
||||||
"mga": "0;35", // short name
|
|
||||||
"magentaB": "1;35", // with bold
|
|
||||||
"mgb": "1;35",
|
|
||||||
"mgaB": "1;35",
|
|
||||||
|
|
||||||
// light/hi tags
|
|
||||||
|
|
||||||
"gray": "0;90",
|
|
||||||
"darkGray": "0;90",
|
|
||||||
"dark_gray": "0;90",
|
|
||||||
"lightYellow": "0;93",
|
|
||||||
"light_yellow": "0;93",
|
|
||||||
"hiYellow": "0;93",
|
|
||||||
"hi_yellow": "0;93",
|
|
||||||
"hiYellowB": "1;93", // with bold
|
|
||||||
"hi_yellow_b": "1;93",
|
|
||||||
"lightMagenta": "0;95",
|
|
||||||
"light_magenta": "0;95",
|
|
||||||
"hiMagenta": "0;95",
|
|
||||||
"hi_magenta": "0;95",
|
|
||||||
"lightMagentaB": "1;95", // with bold
|
|
||||||
"hiMagentaB": "1;95", // with bold
|
|
||||||
"hi_magenta_b": "1;95",
|
|
||||||
"lightRed": "0;91",
|
|
||||||
"light_red": "0;91",
|
|
||||||
"hiRed": "0;91",
|
|
||||||
"hi_red": "0;91",
|
|
||||||
"lightRedB": "1;91", // with bold
|
|
||||||
"light_red_b": "1;91",
|
|
||||||
"hi_red_b": "1;91",
|
|
||||||
"lightGreen": "0;92",
|
|
||||||
"light_green": "0;92",
|
|
||||||
"hiGreen": "0;92",
|
|
||||||
"hi_green": "0;92",
|
|
||||||
"lightGreenB": "1;92",
|
|
||||||
"light_green_b": "1;92",
|
|
||||||
"hi_green_b": "1;92",
|
|
||||||
"lightBlue": "0;94",
|
|
||||||
"light_blue": "0;94",
|
|
||||||
"hiBlue": "0;94",
|
|
||||||
"hi_blue": "0;94",
|
|
||||||
"lightBlueB": "1;94",
|
|
||||||
"light_blue_b": "1;94",
|
|
||||||
"hi_blue_b": "1;94",
|
|
||||||
"lightCyan": "0;96",
|
|
||||||
"light_cyan": "0;96",
|
|
||||||
"hiCyan": "0;96",
|
|
||||||
"hi_cyan": "0;96",
|
|
||||||
"lightCyanB": "1;96",
|
|
||||||
"light_cyan_b": "1;96",
|
|
||||||
"hi_cyan_b": "1;96",
|
|
||||||
"lightWhite": "0;97;40",
|
|
||||||
"light_white": "0;97;40",
|
|
||||||
|
|
||||||
// option
|
|
||||||
"bold": "1",
|
|
||||||
"b": "1",
|
|
||||||
"underscore": "4",
|
|
||||||
"us": "4", // short name for 'underscore'
|
|
||||||
"reverse": "7",
|
|
||||||
|
|
||||||
// alert tags, like bootstrap's alert
|
|
||||||
"suc": "1;32", // same "green" and "bold"
|
|
||||||
"success": "1;32",
|
|
||||||
"info": "0;32", // same "green",
|
|
||||||
"comment": "0;33", // same "brown"
|
|
||||||
"note": "36;1",
|
|
||||||
"notice": "36;4",
|
|
||||||
"warn": "0;1;33",
|
|
||||||
"warning": "0;30;43",
|
|
||||||
"primary": "0;34",
|
|
||||||
"danger": "1;31", // same "red" but add bold
|
|
||||||
"err": "97;41",
|
|
||||||
"error": "97;41", // fg light white; bg red
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* parse color tags
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
var (
|
|
||||||
tagParser = TagParser{}
|
|
||||||
rxNumStr = regexp.MustCompile("^[0-9]{1,3}$")
|
|
||||||
rxHexCode = regexp.MustCompile("^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
|
|
||||||
)
|
|
||||||
|
|
||||||
// TagParser struct
|
|
||||||
type TagParser struct {
|
|
||||||
disable bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTagParser create
|
|
||||||
func NewTagParser() *TagParser {
|
|
||||||
return &TagParser{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// func (tp *TagParser) Disable() *TagParser {
|
|
||||||
// tp.disable = true
|
|
||||||
// return tp
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ParseByEnv parse given string. will check package setting.
|
|
||||||
func (tp *TagParser) ParseByEnv(str string) string {
|
|
||||||
// disable handler TAG
|
|
||||||
if !RenderTag {
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
// disable OR not support color
|
|
||||||
if !Enable || !SupportColor() {
|
|
||||||
return ClearTag(str)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tp.Parse(str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse parse given string, replace color tag and return rendered string
|
|
||||||
func (tp *TagParser) Parse(str string) string {
|
|
||||||
// not contains color tag
|
|
||||||
if !strings.Contains(str, "</>") {
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
// find color tags by regex. str eg: "<fg=white;bg=blue;op=bold>content</>"
|
|
||||||
matched := matchRegex.FindAllStringSubmatch(str, -1)
|
|
||||||
|
|
||||||
// item: 0 full text 1 tag name 2 tag content
|
|
||||||
for _, item := range matched {
|
|
||||||
full, tag, content := item[0], item[1], item[2]
|
|
||||||
|
|
||||||
// use defined tag name: "<info>content</>" -> tag: "info"
|
|
||||||
if !strings.ContainsRune(tag, '=') {
|
|
||||||
code := colorTags[tag]
|
|
||||||
if len(code) > 0 {
|
|
||||||
now := RenderString(code, content)
|
|
||||||
// old := WrapTag(content, tag) is equals to var 'full'
|
|
||||||
str = strings.Replace(str, full, now, 1)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// custom color in tag
|
|
||||||
// - basic: "fg=white;bg=blue;op=bold"
|
|
||||||
if code := ParseCodeFromAttr(tag); len(code) > 0 {
|
|
||||||
now := RenderString(code, content)
|
|
||||||
str = strings.Replace(str, full, now, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return str
|
|
||||||
}
|
|
||||||
|
|
||||||
// func (tp *TagParser) ParseAttr(attr string) (code string) {
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ReplaceTag parse string, replace color tag and return rendered string
|
|
||||||
func ReplaceTag(str string) string {
|
|
||||||
return tagParser.ParseByEnv(str)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseCodeFromAttr parse color attributes.
|
|
||||||
//
|
|
||||||
// attr format:
|
|
||||||
// // VALUE please see var: FgColors, BgColors, AllOptions
|
|
||||||
// "fg=VALUE;bg=VALUE;op=VALUE"
|
|
||||||
// 16 color:
|
|
||||||
// "fg=yellow"
|
|
||||||
// "bg=red"
|
|
||||||
// "op=bold,underscore" option is allow multi value
|
|
||||||
// "fg=white;bg=blue;op=bold"
|
|
||||||
// "fg=white;op=bold,underscore"
|
|
||||||
// 256 color:
|
|
||||||
// "fg=167"
|
|
||||||
// "fg=167;bg=23"
|
|
||||||
// "fg=167;bg=23;op=bold"
|
|
||||||
// true color:
|
|
||||||
// // hex
|
|
||||||
// "fg=fc1cac"
|
|
||||||
// "fg=fc1cac;bg=c2c3c4"
|
|
||||||
// // r,g,b
|
|
||||||
// "fg=23,45,214"
|
|
||||||
// "fg=23,45,214;bg=109,99,88"
|
|
||||||
func ParseCodeFromAttr(attr string) (code string) {
|
|
||||||
if !strings.ContainsRune(attr, '=') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
attr = strings.Trim(attr, ";=,")
|
|
||||||
if len(attr) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var codes []string
|
|
||||||
matched := attrRegex.FindAllStringSubmatch(attr, -1)
|
|
||||||
|
|
||||||
for _, item := range matched {
|
|
||||||
pos, val := item[1], item[2]
|
|
||||||
switch pos {
|
|
||||||
case "fg":
|
|
||||||
if c, ok := FgColors[val]; ok { // basic
|
|
||||||
codes = append(codes, c.String())
|
|
||||||
} else if c, ok := ExFgColors[val]; ok { // extra
|
|
||||||
codes = append(codes, c.String())
|
|
||||||
} else if code := rgbHex256toCode(val, false); code != "" {
|
|
||||||
codes = append(codes, code)
|
|
||||||
}
|
|
||||||
case "bg":
|
|
||||||
if c, ok := BgColors[val]; ok { // basic bg
|
|
||||||
codes = append(codes, c.String())
|
|
||||||
} else if c, ok := ExBgColors[val]; ok { // extra bg
|
|
||||||
codes = append(codes, c.String())
|
|
||||||
} else if code := rgbHex256toCode(val, true); code != "" {
|
|
||||||
codes = append(codes, code)
|
|
||||||
}
|
|
||||||
case "op": // options allow multi value
|
|
||||||
if strings.Contains(val, ",") {
|
|
||||||
ns := strings.Split(val, ",")
|
|
||||||
for _, n := range ns {
|
|
||||||
if c, ok := AllOptions[n]; ok {
|
|
||||||
codes = append(codes, c.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if c, ok := AllOptions[val]; ok {
|
|
||||||
codes = append(codes, c.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(codes, ";")
|
|
||||||
}
|
|
||||||
|
|
||||||
func rgbHex256toCode(val string, isBg bool) (code string) {
|
|
||||||
if len(val) == 6 && rxHexCode.MatchString(val) { // hex: "fc1cac"
|
|
||||||
code = HEX(val, isBg).String()
|
|
||||||
} else if strings.ContainsRune(val, ',') { // rgb: "231,178,161"
|
|
||||||
code = strings.Replace(val, ",", ";", -1)
|
|
||||||
if isBg {
|
|
||||||
code = BgRGBPfx + code
|
|
||||||
} else {
|
|
||||||
code = FgRGBPfx + code
|
|
||||||
}
|
|
||||||
} else if len(val) < 4 && rxNumStr.MatchString(val) { // 256 code
|
|
||||||
if isBg {
|
|
||||||
code = Bg256Pfx + val
|
|
||||||
} else {
|
|
||||||
code = Fg256Pfx + val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearTag clear all tag for a string
|
|
||||||
func ClearTag(s string) string {
|
|
||||||
if !strings.Contains(s, "</>") {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
return stripRegex.ReplaceAllString(s, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* helper methods
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// GetTagCode get color code by tag name
|
|
||||||
func GetTagCode(name string) string {
|
|
||||||
return colorTags[name]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyTag for messages
|
|
||||||
func ApplyTag(tag string, a ...interface{}) string {
|
|
||||||
return RenderCode(GetTagCode(tag), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WrapTag wrap a tag for a string "<tag>content</>"
|
|
||||||
func WrapTag(s string, tag string) string {
|
|
||||||
if s == "" || tag == "" {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("<%s>%s</>", tag, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetColorTags get all internal color tags
|
|
||||||
func GetColorTags() map[string]string {
|
|
||||||
return colorTags
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsDefinedTag is defined tag name
|
|
||||||
func IsDefinedTag(name string) bool {
|
|
||||||
_, ok := colorTags[name]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* Tag extra
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Tag value is a defined style name
|
|
||||||
// Usage:
|
|
||||||
// Tag("info").Println("message")
|
|
||||||
type Tag string
|
|
||||||
|
|
||||||
// Print messages
|
|
||||||
func (tg Tag) Print(a ...interface{}) {
|
|
||||||
name := string(tg)
|
|
||||||
str := fmt.Sprint(a...)
|
|
||||||
|
|
||||||
if stl := GetStyle(name); !stl.IsEmpty() {
|
|
||||||
stl.Print(str)
|
|
||||||
} else {
|
|
||||||
doPrintV2(GetTagCode(name), str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and print messages
|
|
||||||
func (tg Tag) Printf(format string, a ...interface{}) {
|
|
||||||
name := string(tg)
|
|
||||||
str := fmt.Sprintf(format, a...)
|
|
||||||
|
|
||||||
if stl := GetStyle(name); !stl.IsEmpty() {
|
|
||||||
stl.Print(str)
|
|
||||||
} else {
|
|
||||||
doPrintV2(GetTagCode(name), str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println messages line
|
|
||||||
func (tg Tag) Println(a ...interface{}) {
|
|
||||||
name := string(tg)
|
|
||||||
if stl := GetStyle(name); !stl.IsEmpty() {
|
|
||||||
stl.Println(a...)
|
|
||||||
} else {
|
|
||||||
doPrintlnV2(GetTagCode(name), a)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint render messages
|
|
||||||
func (tg Tag) Sprint(a ...interface{}) string {
|
|
||||||
name := string(tg)
|
|
||||||
// if stl := GetStyle(name); !stl.IsEmpty() {
|
|
||||||
// return stl.Render(args...)
|
|
||||||
// }
|
|
||||||
|
|
||||||
return RenderCode(GetTagCode(name), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf format and render messages
|
|
||||||
func (tg Tag) Sprintf(format string, a ...interface{}) string {
|
|
||||||
tag := string(tg)
|
|
||||||
str := fmt.Sprintf(format, a...)
|
|
||||||
|
|
||||||
return RenderString(GetTagCode(tag), str)
|
|
||||||
}
|
|
||||||
593
vendor/github.com/gookit/color/convert.go
generated
vendored
593
vendor/github.com/gookit/color/convert.go
generated
vendored
@@ -1,593 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// ---------- basic(16) <=> 256 color convert ----------
|
|
||||||
basicTo256Map = map[uint8]uint8{
|
|
||||||
30: 0, // black 000000
|
|
||||||
31: 160, // red c51e14
|
|
||||||
32: 34, // green 1dc121
|
|
||||||
33: 184, // yellow c7c329
|
|
||||||
34: 20, // blue 0a2fc4
|
|
||||||
35: 170, // magenta c839c5
|
|
||||||
36: 44, // cyan 20c5c6
|
|
||||||
37: 188, // white c7c7c7
|
|
||||||
90: 59, // lightBlack 686868
|
|
||||||
91: 203, // lightRed fd6f6b
|
|
||||||
92: 83, // lightGreen 67f86f
|
|
||||||
93: 227, // lightYellow fffa72
|
|
||||||
94: 69, // lightBlue 6a76fb
|
|
||||||
95: 213, // lightMagenta fd7cfc
|
|
||||||
96: 87, // lightCyan 68fdfe
|
|
||||||
97: 15, // lightWhite ffffff
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- basic(16) <=> RGB color convert ----------
|
|
||||||
// refer from Hyper app
|
|
||||||
basic2hexMap = map[uint8]string{
|
|
||||||
30: "000000", // black
|
|
||||||
31: "c51e14", // red
|
|
||||||
32: "1dc121", // green
|
|
||||||
33: "c7c329", // yellow
|
|
||||||
34: "0a2fc4", // blue
|
|
||||||
35: "c839c5", // magenta
|
|
||||||
36: "20c5c6", // cyan
|
|
||||||
37: "c7c7c7", // white
|
|
||||||
90: "686868", // lightBlack/darkGray
|
|
||||||
91: "fd6f6b", // lightRed
|
|
||||||
92: "67f86f", // lightGreen
|
|
||||||
93: "fffa72", // lightYellow
|
|
||||||
94: "6a76fb", // lightBlue
|
|
||||||
95: "fd7cfc", // lightMagenta
|
|
||||||
96: "68fdfe", // lightCyan
|
|
||||||
97: "ffffff", // lightWhite
|
|
||||||
}
|
|
||||||
// will convert data from basic2hexMap
|
|
||||||
hex2basicMap = initHex2basicMap()
|
|
||||||
|
|
||||||
// ---------- 256 <=> RGB color convert ----------
|
|
||||||
// adapted from https://gist.github.com/MicahElliott/719710
|
|
||||||
|
|
||||||
c256ToHexMap = init256ToHexMap()
|
|
||||||
|
|
||||||
// rgb to 256 color look-up table
|
|
||||||
// RGB hex => 256 code
|
|
||||||
hexTo256Table = map[string]uint8{
|
|
||||||
// Primary 3-bit (8 colors). Unique representation!
|
|
||||||
"000000": 0,
|
|
||||||
"800000": 1,
|
|
||||||
"008000": 2,
|
|
||||||
"808000": 3,
|
|
||||||
"000080": 4,
|
|
||||||
"800080": 5,
|
|
||||||
"008080": 6,
|
|
||||||
"c0c0c0": 7,
|
|
||||||
|
|
||||||
// Equivalent "bright" versions of original 8 colors.
|
|
||||||
"808080": 8,
|
|
||||||
"ff0000": 9,
|
|
||||||
"00ff00": 10,
|
|
||||||
"ffff00": 11,
|
|
||||||
"0000ff": 12,
|
|
||||||
"ff00ff": 13,
|
|
||||||
"00ffff": 14,
|
|
||||||
"ffffff": 15,
|
|
||||||
|
|
||||||
// values commented out below are duplicates from the prior sections
|
|
||||||
|
|
||||||
// Strictly ascending.
|
|
||||||
// "000000": 16,
|
|
||||||
"000001": 16, // up: avoid key conflicts, value + 1
|
|
||||||
"00005f": 17,
|
|
||||||
"000087": 18,
|
|
||||||
"0000af": 19,
|
|
||||||
"0000d7": 20,
|
|
||||||
// "0000ff": 21,
|
|
||||||
"0000fe": 21, // up: avoid key conflicts, value - 1
|
|
||||||
"005f00": 22,
|
|
||||||
"005f5f": 23,
|
|
||||||
"005f87": 24,
|
|
||||||
"005faf": 25,
|
|
||||||
"005fd7": 26,
|
|
||||||
"005fff": 27,
|
|
||||||
"008700": 28,
|
|
||||||
"00875f": 29,
|
|
||||||
"008787": 30,
|
|
||||||
"0087af": 31,
|
|
||||||
"0087d7": 32,
|
|
||||||
"0087ff": 33,
|
|
||||||
"00af00": 34,
|
|
||||||
"00af5f": 35,
|
|
||||||
"00af87": 36,
|
|
||||||
"00afaf": 37,
|
|
||||||
"00afd7": 38,
|
|
||||||
"00afff": 39,
|
|
||||||
"00d700": 40,
|
|
||||||
"00d75f": 41,
|
|
||||||
"00d787": 42,
|
|
||||||
"00d7af": 43,
|
|
||||||
"00d7d7": 44,
|
|
||||||
"00d7ff": 45,
|
|
||||||
// "00ff00": 46,
|
|
||||||
"00ff01": 46, // up: avoid key conflicts, value + 1
|
|
||||||
"00ff5f": 47,
|
|
||||||
"00ff87": 48,
|
|
||||||
"00ffaf": 49,
|
|
||||||
"00ffd7": 50,
|
|
||||||
// "00ffff": 51,
|
|
||||||
"00fffe": 51, // up: avoid key conflicts, value - 1
|
|
||||||
"5f0000": 52,
|
|
||||||
"5f005f": 53,
|
|
||||||
"5f0087": 54,
|
|
||||||
"5f00af": 55,
|
|
||||||
"5f00d7": 56,
|
|
||||||
"5f00ff": 57,
|
|
||||||
"5f5f00": 58,
|
|
||||||
"5f5f5f": 59,
|
|
||||||
"5f5f87": 60,
|
|
||||||
"5f5faf": 61,
|
|
||||||
"5f5fd7": 62,
|
|
||||||
"5f5fff": 63,
|
|
||||||
"5f8700": 64,
|
|
||||||
"5f875f": 65,
|
|
||||||
"5f8787": 66,
|
|
||||||
"5f87af": 67,
|
|
||||||
"5f87d7": 68,
|
|
||||||
"5f87ff": 69,
|
|
||||||
"5faf00": 70,
|
|
||||||
"5faf5f": 71,
|
|
||||||
"5faf87": 72,
|
|
||||||
"5fafaf": 73,
|
|
||||||
"5fafd7": 74,
|
|
||||||
"5fafff": 75,
|
|
||||||
"5fd700": 76,
|
|
||||||
"5fd75f": 77,
|
|
||||||
"5fd787": 78,
|
|
||||||
"5fd7af": 79,
|
|
||||||
"5fd7d7": 80,
|
|
||||||
"5fd7ff": 81,
|
|
||||||
"5fff00": 82,
|
|
||||||
"5fff5f": 83,
|
|
||||||
"5fff87": 84,
|
|
||||||
"5fffaf": 85,
|
|
||||||
"5fffd7": 86,
|
|
||||||
"5fffff": 87,
|
|
||||||
"870000": 88,
|
|
||||||
"87005f": 89,
|
|
||||||
"870087": 90,
|
|
||||||
"8700af": 91,
|
|
||||||
"8700d7": 92,
|
|
||||||
"8700ff": 93,
|
|
||||||
"875f00": 94,
|
|
||||||
"875f5f": 95,
|
|
||||||
"875f87": 96,
|
|
||||||
"875faf": 97,
|
|
||||||
"875fd7": 98,
|
|
||||||
"875fff": 99,
|
|
||||||
"878700": 100,
|
|
||||||
"87875f": 101,
|
|
||||||
"878787": 102,
|
|
||||||
"8787af": 103,
|
|
||||||
"8787d7": 104,
|
|
||||||
"8787ff": 105,
|
|
||||||
"87af00": 106,
|
|
||||||
"87af5f": 107,
|
|
||||||
"87af87": 108,
|
|
||||||
"87afaf": 109,
|
|
||||||
"87afd7": 110,
|
|
||||||
"87afff": 111,
|
|
||||||
"87d700": 112,
|
|
||||||
"87d75f": 113,
|
|
||||||
"87d787": 114,
|
|
||||||
"87d7af": 115,
|
|
||||||
"87d7d7": 116,
|
|
||||||
"87d7ff": 117,
|
|
||||||
"87ff00": 118,
|
|
||||||
"87ff5f": 119,
|
|
||||||
"87ff87": 120,
|
|
||||||
"87ffaf": 121,
|
|
||||||
"87ffd7": 122,
|
|
||||||
"87ffff": 123,
|
|
||||||
"af0000": 124,
|
|
||||||
"af005f": 125,
|
|
||||||
"af0087": 126,
|
|
||||||
"af00af": 127,
|
|
||||||
"af00d7": 128,
|
|
||||||
"af00ff": 129,
|
|
||||||
"af5f00": 130,
|
|
||||||
"af5f5f": 131,
|
|
||||||
"af5f87": 132,
|
|
||||||
"af5faf": 133,
|
|
||||||
"af5fd7": 134,
|
|
||||||
"af5fff": 135,
|
|
||||||
"af8700": 136,
|
|
||||||
"af875f": 137,
|
|
||||||
"af8787": 138,
|
|
||||||
"af87af": 139,
|
|
||||||
"af87d7": 140,
|
|
||||||
"af87ff": 141,
|
|
||||||
"afaf00": 142,
|
|
||||||
"afaf5f": 143,
|
|
||||||
"afaf87": 144,
|
|
||||||
"afafaf": 145,
|
|
||||||
"afafd7": 146,
|
|
||||||
"afafff": 147,
|
|
||||||
"afd700": 148,
|
|
||||||
"afd75f": 149,
|
|
||||||
"afd787": 150,
|
|
||||||
"afd7af": 151,
|
|
||||||
"afd7d7": 152,
|
|
||||||
"afd7ff": 153,
|
|
||||||
"afff00": 154,
|
|
||||||
"afff5f": 155,
|
|
||||||
"afff87": 156,
|
|
||||||
"afffaf": 157,
|
|
||||||
"afffd7": 158,
|
|
||||||
"afffff": 159,
|
|
||||||
"d70000": 160,
|
|
||||||
"d7005f": 161,
|
|
||||||
"d70087": 162,
|
|
||||||
"d700af": 163,
|
|
||||||
"d700d7": 164,
|
|
||||||
"d700ff": 165,
|
|
||||||
"d75f00": 166,
|
|
||||||
"d75f5f": 167,
|
|
||||||
"d75f87": 168,
|
|
||||||
"d75faf": 169,
|
|
||||||
"d75fd7": 170,
|
|
||||||
"d75fff": 171,
|
|
||||||
"d78700": 172,
|
|
||||||
"d7875f": 173,
|
|
||||||
"d78787": 174,
|
|
||||||
"d787af": 175,
|
|
||||||
"d787d7": 176,
|
|
||||||
"d787ff": 177,
|
|
||||||
"d7af00": 178,
|
|
||||||
"d7af5f": 179,
|
|
||||||
"d7af87": 180,
|
|
||||||
"d7afaf": 181,
|
|
||||||
"d7afd7": 182,
|
|
||||||
"d7afff": 183,
|
|
||||||
"d7d700": 184,
|
|
||||||
"d7d75f": 185,
|
|
||||||
"d7d787": 186,
|
|
||||||
"d7d7af": 187,
|
|
||||||
"d7d7d7": 188,
|
|
||||||
"d7d7ff": 189,
|
|
||||||
"d7ff00": 190,
|
|
||||||
"d7ff5f": 191,
|
|
||||||
"d7ff87": 192,
|
|
||||||
"d7ffaf": 193,
|
|
||||||
"d7ffd7": 194,
|
|
||||||
"d7ffff": 195,
|
|
||||||
// "ff0000": 196,
|
|
||||||
"ff0001": 196, // up: avoid key conflicts, value + 1
|
|
||||||
"ff005f": 197,
|
|
||||||
"ff0087": 198,
|
|
||||||
"ff00af": 199,
|
|
||||||
"ff00d7": 200,
|
|
||||||
// "ff00ff": 201,
|
|
||||||
"ff00fe": 201, // up: avoid key conflicts, value - 1
|
|
||||||
"ff5f00": 202,
|
|
||||||
"ff5f5f": 203,
|
|
||||||
"ff5f87": 204,
|
|
||||||
"ff5faf": 205,
|
|
||||||
"ff5fd7": 206,
|
|
||||||
"ff5fff": 207,
|
|
||||||
"ff8700": 208,
|
|
||||||
"ff875f": 209,
|
|
||||||
"ff8787": 210,
|
|
||||||
"ff87af": 211,
|
|
||||||
"ff87d7": 212,
|
|
||||||
"ff87ff": 213,
|
|
||||||
"ffaf00": 214,
|
|
||||||
"ffaf5f": 215,
|
|
||||||
"ffaf87": 216,
|
|
||||||
"ffafaf": 217,
|
|
||||||
"ffafd7": 218,
|
|
||||||
"ffafff": 219,
|
|
||||||
"ffd700": 220,
|
|
||||||
"ffd75f": 221,
|
|
||||||
"ffd787": 222,
|
|
||||||
"ffd7af": 223,
|
|
||||||
"ffd7d7": 224,
|
|
||||||
"ffd7ff": 225,
|
|
||||||
// "ffff00": 226,
|
|
||||||
"ffff01": 226, // up: avoid key conflicts, value + 1
|
|
||||||
"ffff5f": 227,
|
|
||||||
"ffff87": 228,
|
|
||||||
"ffffaf": 229,
|
|
||||||
"ffffd7": 230,
|
|
||||||
// "ffffff": 231,
|
|
||||||
"fffffe": 231, // up: avoid key conflicts, value - 1
|
|
||||||
|
|
||||||
// Gray-scale range.
|
|
||||||
"080808": 232,
|
|
||||||
"121212": 233,
|
|
||||||
"1c1c1c": 234,
|
|
||||||
"262626": 235,
|
|
||||||
"303030": 236,
|
|
||||||
"3a3a3a": 237,
|
|
||||||
"444444": 238,
|
|
||||||
"4e4e4e": 239,
|
|
||||||
"585858": 240,
|
|
||||||
"626262": 241,
|
|
||||||
"6c6c6c": 242,
|
|
||||||
"767676": 243,
|
|
||||||
// "808080": 244,
|
|
||||||
"808081": 244, // up: avoid key conflicts, value + 1
|
|
||||||
"8a8a8a": 245,
|
|
||||||
"949494": 246,
|
|
||||||
"9e9e9e": 247,
|
|
||||||
"a8a8a8": 248,
|
|
||||||
"b2b2b2": 249,
|
|
||||||
"bcbcbc": 250,
|
|
||||||
"c6c6c6": 251,
|
|
||||||
"d0d0d0": 252,
|
|
||||||
"dadada": 253,
|
|
||||||
"e4e4e4": 254,
|
|
||||||
"eeeeee": 255,
|
|
||||||
}
|
|
||||||
|
|
||||||
incs = []uint8{0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff}
|
|
||||||
)
|
|
||||||
|
|
||||||
func initHex2basicMap() map[string]uint8 {
|
|
||||||
h2b := make(map[string]uint8, len(basic2hexMap))
|
|
||||||
// ini data map
|
|
||||||
for u, s := range basic2hexMap {
|
|
||||||
h2b[s] = u
|
|
||||||
}
|
|
||||||
return h2b
|
|
||||||
}
|
|
||||||
|
|
||||||
func init256ToHexMap() map[uint8]string {
|
|
||||||
c256toh := make(map[uint8]string, len(hexTo256Table))
|
|
||||||
// ini data map
|
|
||||||
for hex, c256 := range hexTo256Table {
|
|
||||||
c256toh[c256] = hex
|
|
||||||
}
|
|
||||||
return c256toh
|
|
||||||
}
|
|
||||||
|
|
||||||
// RgbTo256Table mapping data
|
|
||||||
func RgbTo256Table() map[string]uint8 {
|
|
||||||
return hexTo256Table
|
|
||||||
}
|
|
||||||
|
|
||||||
// Colors2code convert colors to code. return like "32;45;3"
|
|
||||||
func Colors2code(colors ...Color) string {
|
|
||||||
if len(colors) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var codes []string
|
|
||||||
for _, color := range colors {
|
|
||||||
codes = append(codes, color.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.Join(codes, ";")
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* HEX code <=> RGB/True color code
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Hex2rgb alias of the HexToRgb()
|
|
||||||
func Hex2rgb(hex string) []int { return HexToRgb(hex) }
|
|
||||||
|
|
||||||
// HexToRGB alias of the HexToRgb()
|
|
||||||
func HexToRGB(hex string) []int { return HexToRgb(hex) }
|
|
||||||
|
|
||||||
// HexToRgb convert hex color string to RGB numbers
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// rgb := HexToRgb("ccc") // rgb: [204 204 204]
|
|
||||||
// rgb := HexToRgb("aabbcc") // rgb: [170 187 204]
|
|
||||||
// rgb := HexToRgb("#aabbcc") // rgb: [170 187 204]
|
|
||||||
// rgb := HexToRgb("0xad99c0") // rgb: [170 187 204]
|
|
||||||
func HexToRgb(hex string) (rgb []int) {
|
|
||||||
hex = strings.TrimSpace(hex)
|
|
||||||
if hex == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// like from css. eg "#ccc" "#ad99c0"
|
|
||||||
if hex[0] == '#' {
|
|
||||||
hex = hex[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
hex = strings.ToLower(hex)
|
|
||||||
switch len(hex) {
|
|
||||||
case 3: // "ccc"
|
|
||||||
hex = string([]byte{hex[0], hex[0], hex[1], hex[1], hex[2], hex[2]})
|
|
||||||
case 8: // "0xad99c0"
|
|
||||||
hex = strings.TrimPrefix(hex, "0x")
|
|
||||||
}
|
|
||||||
|
|
||||||
// recheck
|
|
||||||
if len(hex) != 6 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert string to int64
|
|
||||||
if i64, err := strconv.ParseInt(hex, 16, 32); err == nil {
|
|
||||||
color := int(i64)
|
|
||||||
// parse int
|
|
||||||
rgb = make([]int, 3)
|
|
||||||
rgb[0] = color >> 16
|
|
||||||
rgb[1] = (color & 0x00FF00) >> 8
|
|
||||||
rgb[2] = color & 0x0000FF
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rgb2hex alias of the RgbToHex()
|
|
||||||
func Rgb2hex(rgb []int) string { return RgbToHex(rgb) }
|
|
||||||
|
|
||||||
// RgbToHex convert RGB-code to hex-code
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// hex := RgbToHex([]int{170, 187, 204}) // hex: "aabbcc"
|
|
||||||
func RgbToHex(rgb []int) string {
|
|
||||||
hexNodes := make([]string, len(rgb))
|
|
||||||
|
|
||||||
for _, v := range rgb {
|
|
||||||
hexNodes = append(hexNodes, strconv.FormatInt(int64(v), 16))
|
|
||||||
}
|
|
||||||
return strings.Join(hexNodes, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* 4bit(16) color <=> RGB/True color
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Basic2hex convert basic color to hex string.
|
|
||||||
func Basic2hex(val uint8) string {
|
|
||||||
return basic2hexMap[val]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hex2basic convert hex string to basic color code.
|
|
||||||
func Hex2basic(hex string) uint8 {
|
|
||||||
return hex2basicMap[hex]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rgb2basic alias of the RgbToAnsi()
|
|
||||||
func Rgb2basic(r, g, b uint8, isBg bool) uint8 {
|
|
||||||
// is basic color, direct use static map data.
|
|
||||||
hex := RgbToHex([]int{int(r), int(g), int(b)})
|
|
||||||
if val, ok := hex2basicMap[hex]; ok {
|
|
||||||
if isBg {
|
|
||||||
return val + 10
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
return RgbToAnsi(r, g, b, isBg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rgb2ansi alias of the RgbToAnsi()
|
|
||||||
func Rgb2ansi(r, g, b uint8, isBg bool) uint8 {
|
|
||||||
return RgbToAnsi(r, g, b, isBg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RgbToAnsi convert RGB-code to 16-code
|
|
||||||
// refer https://github.com/radareorg/radare2/blob/master/libr/cons/rgb.c#L249-L271
|
|
||||||
func RgbToAnsi(r, g, b uint8, isBg bool) uint8 {
|
|
||||||
var bright, c, k uint8
|
|
||||||
base := compareVal(isBg, BgBase, FgBase)
|
|
||||||
|
|
||||||
// eco bright-specific
|
|
||||||
if r == 0x80 && g == 0x80 && b == 0x80 { // 0x80=128
|
|
||||||
bright = 53
|
|
||||||
} else if r == 0xff || g == 0xff || b == 0xff { // 0xff=255
|
|
||||||
bright = 60
|
|
||||||
} // else bright = 0
|
|
||||||
|
|
||||||
if r == g && g == b {
|
|
||||||
// 0x7f=127
|
|
||||||
// r = (r > 0x7f) ? 1 : 0;
|
|
||||||
r = compareVal(r > 0x7f, 1, 0)
|
|
||||||
g = compareVal(g > 0x7f, 1, 0)
|
|
||||||
b = compareVal(b > 0x7f, 1, 0)
|
|
||||||
} else {
|
|
||||||
k = (r + g + b) / 3
|
|
||||||
|
|
||||||
// r = (r >= k) ? 1 : 0;
|
|
||||||
r = compareVal(r >= k, 1, 0)
|
|
||||||
g = compareVal(g >= k, 1, 0)
|
|
||||||
b = compareVal(b >= k, 1, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// c = (r ? 1 : 0) + (g ? (b ? 6 : 2) : (b ? 4 : 0))
|
|
||||||
c = compareVal(r > 0, 1, 0)
|
|
||||||
|
|
||||||
if g > 0 {
|
|
||||||
c += compareVal(b > 0, 6, 2)
|
|
||||||
} else {
|
|
||||||
c += compareVal(b > 0, 4, 0)
|
|
||||||
}
|
|
||||||
return base + bright + c
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* 8bit(256) color <=> RGB/True color
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Rgb2short convert RGB-code to 256-code
|
|
||||||
func Rgb2short(r, g, b uint8) uint8 {
|
|
||||||
return RgbTo256(r, g, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RgbTo256 convert RGB-code to 256-code
|
|
||||||
func RgbTo256(r, g, b uint8) uint8 {
|
|
||||||
res := make([]uint8, 3)
|
|
||||||
for partI, part := range [3]uint8{r, g, b} {
|
|
||||||
i := 0
|
|
||||||
for i < len(incs)-1 {
|
|
||||||
s, b := incs[i], incs[i+1] // smaller, bigger
|
|
||||||
if s <= part && part <= b {
|
|
||||||
s1 := math.Abs(float64(s) - float64(part))
|
|
||||||
b1 := math.Abs(float64(b) - float64(part))
|
|
||||||
var closest uint8
|
|
||||||
if s1 < b1 {
|
|
||||||
closest = s
|
|
||||||
} else {
|
|
||||||
closest = b
|
|
||||||
}
|
|
||||||
res[partI] = closest
|
|
||||||
break
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hex := fmt.Sprintf("%02x%02x%02x", res[0], res[1], res[2])
|
|
||||||
equiv := hexTo256Table[hex]
|
|
||||||
return equiv
|
|
||||||
}
|
|
||||||
|
|
||||||
// C256ToRgb convert an 256 color code to RGB numbers
|
|
||||||
func C256ToRgb(val uint8) (rgb []uint8) {
|
|
||||||
hex := c256ToHexMap[val]
|
|
||||||
// convert to rgb code
|
|
||||||
rgbInts := Hex2rgb(hex)
|
|
||||||
|
|
||||||
return []uint8{
|
|
||||||
uint8(rgbInts[0]),
|
|
||||||
uint8(rgbInts[1]),
|
|
||||||
uint8(rgbInts[2]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// C256ToRgbV1 convert an 256 color code to RGB numbers
|
|
||||||
// refer https://github.com/torvalds/linux/commit/cec5b2a97a11ade56a701e83044d0a2a984c67b4
|
|
||||||
func C256ToRgbV1(val uint8) (rgb []uint8) {
|
|
||||||
var r, g, b uint8
|
|
||||||
if val < 8 { // Standard colours.
|
|
||||||
// r = val&1 ? 0xaa : 0x00;
|
|
||||||
r = compareVal(val&1 == 1, 0xaa, 0x00)
|
|
||||||
g = compareVal(val&2 == 2, 0xaa, 0x00)
|
|
||||||
b = compareVal(val&4 == 4, 0xaa, 0x00)
|
|
||||||
} else if val < 16 {
|
|
||||||
// r = val & 1 ? 0xff : 0x55;
|
|
||||||
r = compareVal(val&1 == 1, 0xff, 0x55)
|
|
||||||
g = compareVal(val&2 == 2, 0xff, 0x55)
|
|
||||||
b = compareVal(val&4 == 4, 0xff, 0x55)
|
|
||||||
} else if val < 232 { /* 6x6x6 colour cube. */
|
|
||||||
r = (val - 16) / 36 * 85 / 2
|
|
||||||
g = (val - 16) / 6 % 6 * 85 / 2
|
|
||||||
b = (val - 16) % 6 * 85 / 2
|
|
||||||
} else { /* Grayscale ramp. */
|
|
||||||
nv := uint8(int(val)*10 - 2312)
|
|
||||||
// set value
|
|
||||||
r, g, b = nv, nv, nv
|
|
||||||
}
|
|
||||||
|
|
||||||
return []uint8{r, g, b}
|
|
||||||
}
|
|
||||||
281
vendor/github.com/gookit/color/detect_env.go
generated
vendored
281
vendor/github.com/gookit/color/detect_env.go
generated
vendored
@@ -1,281 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/xo/terminfo"
|
|
||||||
)
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* helper methods for detect color supports
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// DetectColorLevel for current env
|
|
||||||
//
|
|
||||||
// NOTICE: The method will detect terminal info each times,
|
|
||||||
// if only want get current color level, please direct call SupportColor() or TermColorLevel()
|
|
||||||
func DetectColorLevel() terminfo.ColorLevel {
|
|
||||||
level, _ := detectTermColorLevel()
|
|
||||||
return level
|
|
||||||
}
|
|
||||||
|
|
||||||
// detect terminal color support level
|
|
||||||
//
|
|
||||||
// refer https://github.com/Delta456/box-cli-maker
|
|
||||||
func detectTermColorLevel() (level terminfo.ColorLevel, needVTP bool) {
|
|
||||||
// on windows WSL:
|
|
||||||
// - runtime.GOOS == "Linux"
|
|
||||||
// - support true-color
|
|
||||||
// env:
|
|
||||||
// WSL_DISTRO_NAME=Debian
|
|
||||||
if val := os.Getenv("WSL_DISTRO_NAME"); val != "" {
|
|
||||||
// detect WSL as it has True Color support
|
|
||||||
if detectWSL() {
|
|
||||||
debugf("True Color support on WSL environment")
|
|
||||||
return terminfo.ColorLevelMillions, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isWin := runtime.GOOS == "windows"
|
|
||||||
termVal := os.Getenv("TERM")
|
|
||||||
|
|
||||||
// on TERM=screen: not support true-color
|
|
||||||
if termVal != "screen" {
|
|
||||||
// On JetBrains Terminal
|
|
||||||
// - support true-color
|
|
||||||
// env:
|
|
||||||
// TERMINAL_EMULATOR=JetBrains-JediTerm
|
|
||||||
val := os.Getenv("TERMINAL_EMULATOR")
|
|
||||||
if val == "JetBrains-JediTerm" {
|
|
||||||
debugf("True Color support on JetBrains-JediTerm, is win: %v", isWin)
|
|
||||||
return terminfo.ColorLevelMillions, isWin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// level, err = terminfo.ColorLevelFromEnv()
|
|
||||||
level = detectColorLevelFromEnv(termVal, isWin)
|
|
||||||
debugf("color level by detectColorLevelFromEnv: %s", level.String())
|
|
||||||
|
|
||||||
// fallback: simple detect by TERM value string.
|
|
||||||
if level == terminfo.ColorLevelNone {
|
|
||||||
debugf("level none - fallback check special term color support")
|
|
||||||
// on Windows: enable VTP as it has True Color support
|
|
||||||
level, needVTP = detectSpecialTermColor(termVal)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectColorFromEnv returns the color level COLORTERM, FORCE_COLOR,
|
|
||||||
// TERM_PROGRAM, or determined from the TERM environment variable.
|
|
||||||
//
|
|
||||||
// refer the terminfo.ColorLevelFromEnv()
|
|
||||||
// https://en.wikipedia.org/wiki/Terminfo
|
|
||||||
func detectColorLevelFromEnv(termVal string, isWin bool) terminfo.ColorLevel {
|
|
||||||
// check for overriding environment variables
|
|
||||||
colorTerm, termProg, forceColor := os.Getenv("COLORTERM"), os.Getenv("TERM_PROGRAM"), os.Getenv("FORCE_COLOR")
|
|
||||||
switch {
|
|
||||||
case strings.Contains(colorTerm, "truecolor") || strings.Contains(colorTerm, "24bit"):
|
|
||||||
if termVal == "screen" { // on TERM=screen: not support true-color
|
|
||||||
return terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
return terminfo.ColorLevelMillions
|
|
||||||
case colorTerm != "" || forceColor != "":
|
|
||||||
return terminfo.ColorLevelBasic
|
|
||||||
case termProg == "Apple_Terminal":
|
|
||||||
return terminfo.ColorLevelHundreds
|
|
||||||
case termProg == "Terminus" || termProg == "Hyper":
|
|
||||||
if termVal == "screen" { // on TERM=screen: not support true-color
|
|
||||||
return terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
return terminfo.ColorLevelMillions
|
|
||||||
case termProg == "iTerm.app":
|
|
||||||
if termVal == "screen" { // on TERM=screen: not support true-color
|
|
||||||
return terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
|
|
||||||
// check iTerm version
|
|
||||||
ver := os.Getenv("TERM_PROGRAM_VERSION")
|
|
||||||
if ver != "" {
|
|
||||||
i, err := strconv.Atoi(strings.Split(ver, ".")[0])
|
|
||||||
if err != nil {
|
|
||||||
saveInternalError(terminfo.ErrInvalidTermProgramVersion)
|
|
||||||
// return terminfo.ColorLevelNone
|
|
||||||
return terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
if i == 3 {
|
|
||||||
return terminfo.ColorLevelMillions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise determine from TERM's max_colors capability
|
|
||||||
if !isWin && termVal != "" {
|
|
||||||
debugf("TERM=%s - check color level by load terminfo file", termVal)
|
|
||||||
ti, err := terminfo.Load(termVal)
|
|
||||||
if err != nil {
|
|
||||||
saveInternalError(err)
|
|
||||||
return terminfo.ColorLevelNone
|
|
||||||
}
|
|
||||||
|
|
||||||
debugf("the loaded term info file is: %s", ti.File)
|
|
||||||
v, ok := ti.Nums[terminfo.MaxColors]
|
|
||||||
switch {
|
|
||||||
case !ok || v <= 16:
|
|
||||||
return terminfo.ColorLevelNone
|
|
||||||
case ok && v >= 256:
|
|
||||||
return terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
return terminfo.ColorLevelBasic
|
|
||||||
}
|
|
||||||
|
|
||||||
// no TERM env value. default return none level
|
|
||||||
return terminfo.ColorLevelNone
|
|
||||||
// return terminfo.ColorLevelBasic
|
|
||||||
}
|
|
||||||
|
|
||||||
var detectedWSL bool
|
|
||||||
var wslContents string
|
|
||||||
|
|
||||||
// https://github.com/Microsoft/WSL/issues/423#issuecomment-221627364
|
|
||||||
func detectWSL() bool {
|
|
||||||
if !detectedWSL {
|
|
||||||
b := make([]byte, 1024)
|
|
||||||
// `cat /proc/version`
|
|
||||||
// on mac:
|
|
||||||
// !not the file!
|
|
||||||
// on linux(debian,ubuntu,alpine):
|
|
||||||
// Linux version 4.19.121-linuxkit (root@18b3f92ade35) (gcc version 9.2.0 (Alpine 9.2.0)) #1 SMP Thu Jan 21 15:36:34 UTC 2021
|
|
||||||
// on win git bash, conEmu:
|
|
||||||
// MINGW64_NT-10.0-19042 version 3.1.7-340.x86_64 (@WIN-N0G619FD3UK) (gcc version 9.3.0 (GCC) ) 2020-10-23 13:08 UTC
|
|
||||||
// on WSL:
|
|
||||||
// Linux version 4.4.0-19041-Microsoft (Microsoft@Microsoft.com) (gcc version 5.4.0 (GCC) ) #488-Microsoft Mon Sep 01 13:43:00 PST 2020
|
|
||||||
f, err := os.Open("/proc/version")
|
|
||||||
if err == nil {
|
|
||||||
_, _ = f.Read(b) // ignore error
|
|
||||||
if err = f.Close(); err != nil {
|
|
||||||
saveInternalError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
wslContents = string(b)
|
|
||||||
}
|
|
||||||
detectedWSL = true
|
|
||||||
}
|
|
||||||
return strings.Contains(wslContents, "Microsoft")
|
|
||||||
}
|
|
||||||
|
|
||||||
// refer
|
|
||||||
// https://github.com/Delta456/box-cli-maker/blob/7b5a1ad8a016ce181e7d8b05e24b54ff60b4b38a/detect_unix.go#L27-L45
|
|
||||||
// detect WSL as it has True Color support
|
|
||||||
func isWSL() bool {
|
|
||||||
// on windows WSL:
|
|
||||||
// - runtime.GOOS == "Linux"
|
|
||||||
// - support true-color
|
|
||||||
// WSL_DISTRO_NAME=Debian
|
|
||||||
if val := os.Getenv("WSL_DISTRO_NAME"); val == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// `cat /proc/sys/kernel/osrelease`
|
|
||||||
// on mac:
|
|
||||||
// !not the file!
|
|
||||||
// on linux:
|
|
||||||
// 4.19.121-linuxkit
|
|
||||||
// on WSL Output:
|
|
||||||
// 4.4.0-19041-Microsoft
|
|
||||||
wsl, err := ioutil.ReadFile("/proc/sys/kernel/osrelease")
|
|
||||||
if err != nil {
|
|
||||||
saveInternalError(err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// it gives "Microsoft" for WSL and "microsoft" for WSL 2
|
|
||||||
// it support True-color
|
|
||||||
content := strings.ToLower(string(wsl))
|
|
||||||
return strings.Contains(content, "microsoft")
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* helper methods for check env
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// IsWindows OS env
|
|
||||||
func IsWindows() bool {
|
|
||||||
return runtime.GOOS == "windows"
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsConsole Determine whether w is one of stderr, stdout, stdin
|
|
||||||
func IsConsole(w io.Writer) bool {
|
|
||||||
o, ok := w.(*os.File)
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fd := o.Fd()
|
|
||||||
|
|
||||||
// fix: cannot use 'o == os.Stdout' to compare
|
|
||||||
return fd == uintptr(syscall.Stdout) || fd == uintptr(syscall.Stdin) || fd == uintptr(syscall.Stderr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsMSys msys(MINGW64) environment, does not necessarily support color
|
|
||||||
func IsMSys() bool {
|
|
||||||
// like "MSYSTEM=MINGW64"
|
|
||||||
if len(os.Getenv("MSYSTEM")) > 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSupportColor check current console is support color.
|
|
||||||
//
|
|
||||||
// NOTICE: The method will detect terminal info each times,
|
|
||||||
// if only want get current color level, please direct call SupportColor() or TermColorLevel()
|
|
||||||
func IsSupportColor() bool {
|
|
||||||
return IsSupport16Color()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSupportColor check current console is support color.
|
|
||||||
//
|
|
||||||
// NOTICE: The method will detect terminal info each times,
|
|
||||||
// if only want get current color level, please direct call SupportColor() or TermColorLevel()
|
|
||||||
func IsSupport16Color() bool {
|
|
||||||
level, _ := detectTermColorLevel()
|
|
||||||
return level > terminfo.ColorLevelNone
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSupport256Color render check
|
|
||||||
//
|
|
||||||
// NOTICE: The method will detect terminal info each times,
|
|
||||||
// if only want get current color level, please direct call SupportColor() or TermColorLevel()
|
|
||||||
func IsSupport256Color() bool {
|
|
||||||
level, _ := detectTermColorLevel()
|
|
||||||
return level > terminfo.ColorLevelBasic
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSupportRGBColor check. alias of the IsSupportTrueColor()
|
|
||||||
//
|
|
||||||
// NOTICE: The method will detect terminal info each times,
|
|
||||||
// if only want get current color level, please direct call SupportColor() or TermColorLevel()
|
|
||||||
func IsSupportRGBColor() bool {
|
|
||||||
return IsSupportTrueColor()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsSupportTrueColor render check.
|
|
||||||
//
|
|
||||||
// NOTICE: The method will detect terminal info each times,
|
|
||||||
// if only want get current color level, please direct call SupportColor() or TermColorLevel()
|
|
||||||
//
|
|
||||||
// ENV:
|
|
||||||
// "COLORTERM=truecolor"
|
|
||||||
// "COLORTERM=24bit"
|
|
||||||
func IsSupportTrueColor() bool {
|
|
||||||
level, _ := detectTermColorLevel()
|
|
||||||
return level > terminfo.ColorLevelHundreds
|
|
||||||
}
|
|
||||||
48
vendor/github.com/gookit/color/detect_nonwin.go
generated
vendored
48
vendor/github.com/gookit/color/detect_nonwin.go
generated
vendored
@@ -1,48 +0,0 @@
|
|||||||
// +build !windows
|
|
||||||
|
|
||||||
// The method in the file has no effect
|
|
||||||
// Only for compatibility with non-Windows systems
|
|
||||||
|
|
||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/xo/terminfo"
|
|
||||||
)
|
|
||||||
|
|
||||||
// detect special term color support
|
|
||||||
func detectSpecialTermColor(termVal string) (terminfo.ColorLevel, bool) {
|
|
||||||
if termVal == "" {
|
|
||||||
return terminfo.ColorLevelNone, false
|
|
||||||
}
|
|
||||||
|
|
||||||
debugf("terminfo check fail - fallback detect color by check TERM value")
|
|
||||||
|
|
||||||
// on TERM=screen:
|
|
||||||
// - support 256, not support true-color. test on macOS
|
|
||||||
if termVal == "screen" {
|
|
||||||
return terminfo.ColorLevelHundreds, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(termVal, "256color") {
|
|
||||||
return terminfo.ColorLevelHundreds, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.Contains(termVal, "xterm") {
|
|
||||||
return terminfo.ColorLevelHundreds, false
|
|
||||||
// return terminfo.ColorLevelBasic, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// return terminfo.ColorLevelNone, nil
|
|
||||||
return terminfo.ColorLevelBasic, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsTerminal returns true if the given file descriptor is a terminal.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// IsTerminal(os.Stdout.Fd())
|
|
||||||
func IsTerminal(fd uintptr) bool {
|
|
||||||
return fd == uintptr(syscall.Stdout) || fd == uintptr(syscall.Stdin) || fd == uintptr(syscall.Stderr)
|
|
||||||
}
|
|
||||||
243
vendor/github.com/gookit/color/detect_windows.go
generated
vendored
243
vendor/github.com/gookit/color/detect_windows.go
generated
vendored
@@ -1,243 +0,0 @@
|
|||||||
// +build windows
|
|
||||||
|
|
||||||
// Display color on windows
|
|
||||||
// refer:
|
|
||||||
// golang.org/x/sys/windows
|
|
||||||
// golang.org/x/crypto/ssh/terminal
|
|
||||||
// https://docs.microsoft.com/en-us/windows/console
|
|
||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"syscall"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/xo/terminfo"
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
// related docs
|
|
||||||
// https://docs.microsoft.com/zh-cn/windows/console/console-virtual-terminal-sequences
|
|
||||||
// https://docs.microsoft.com/zh-cn/windows/console/console-virtual-terminal-sequences#samples
|
|
||||||
var (
|
|
||||||
// isMSys bool
|
|
||||||
kernel32 *syscall.LazyDLL
|
|
||||||
|
|
||||||
procGetConsoleMode *syscall.LazyProc
|
|
||||||
procSetConsoleMode *syscall.LazyProc
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
if !SupportColor() {
|
|
||||||
isLikeInCmd = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// if disabled.
|
|
||||||
if !Enable {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// if at windows's ConEmu, Cmder, putty ... terminals not need VTP
|
|
||||||
|
|
||||||
// -------- try force enable colors on windows terminal -------
|
|
||||||
tryEnableVTP(needVTP)
|
|
||||||
|
|
||||||
// fetch console screen buffer info
|
|
||||||
// err := getConsoleScreenBufferInfo(uintptr(syscall.Stdout), &defScreenInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// try force enable colors on windows terminal
|
|
||||||
func tryEnableVTP(enable bool) bool {
|
|
||||||
if !enable {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
debugf("True-Color by enable VirtualTerminalProcessing on windows")
|
|
||||||
|
|
||||||
initKernel32Proc()
|
|
||||||
|
|
||||||
// enable colors on windows terminal
|
|
||||||
if tryEnableOnCONOUT() {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return tryEnableOnStdout()
|
|
||||||
}
|
|
||||||
|
|
||||||
func initKernel32Proc() {
|
|
||||||
if kernel32 != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// load related windows dll
|
|
||||||
// https://docs.microsoft.com/en-us/windows/console/setconsolemode
|
|
||||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
|
||||||
|
|
||||||
procGetConsoleMode = kernel32.NewProc("GetConsoleMode")
|
|
||||||
procSetConsoleMode = kernel32.NewProc("SetConsoleMode")
|
|
||||||
}
|
|
||||||
|
|
||||||
func tryEnableOnCONOUT() bool {
|
|
||||||
outHandle, err := syscall.Open("CONOUT$", syscall.O_RDWR, 0)
|
|
||||||
if err != nil {
|
|
||||||
saveInternalError(err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
err = EnableVirtualTerminalProcessing(outHandle, true)
|
|
||||||
if err != nil {
|
|
||||||
saveInternalError(err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func tryEnableOnStdout() bool {
|
|
||||||
// try direct open syscall.Stdout
|
|
||||||
err := EnableVirtualTerminalProcessing(syscall.Stdout, true)
|
|
||||||
if err != nil {
|
|
||||||
saveInternalError(err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the Windows Version and Build Number
|
|
||||||
var (
|
|
||||||
winVersion, _, buildNumber = windows.RtlGetNtVersionNumbers()
|
|
||||||
)
|
|
||||||
|
|
||||||
// refer
|
|
||||||
// https://github.com/Delta456/box-cli-maker/blob/7b5a1ad8a016ce181e7d8b05e24b54ff60b4b38a/detect_windows.go#L30-L57
|
|
||||||
// https://github.com/gookit/color/issues/25#issuecomment-738727917
|
|
||||||
// detects the Color Level Supported on windows: cmd, powerShell
|
|
||||||
func detectSpecialTermColor(termVal string) (tl terminfo.ColorLevel, needVTP bool) {
|
|
||||||
if os.Getenv("ConEmuANSI") == "ON" {
|
|
||||||
debugf("support True Color by ConEmuANSI=ON")
|
|
||||||
// ConEmuANSI is "ON" for generic ANSI support
|
|
||||||
// but True Color option is enabled by default
|
|
||||||
// I am just assuming that people wouldn't have disabled it
|
|
||||||
// Even if it is not enabled then ConEmu will auto round off
|
|
||||||
// accordingly
|
|
||||||
return terminfo.ColorLevelMillions, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Before Windows 10 Build Number 10586, console never supported ANSI Colors
|
|
||||||
if buildNumber < 10586 || winVersion < 10 {
|
|
||||||
// Detect if using ANSICON on older systems
|
|
||||||
if os.Getenv("ANSICON") != "" {
|
|
||||||
conVersion := os.Getenv("ANSICON_VER")
|
|
||||||
// 8 bit Colors were only supported after v1.81 release
|
|
||||||
if conVersion >= "181" {
|
|
||||||
return terminfo.ColorLevelHundreds, false
|
|
||||||
}
|
|
||||||
return terminfo.ColorLevelBasic, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return terminfo.ColorLevelNone, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// True Color is not available before build 14931 so fallback to 8 bit color.
|
|
||||||
if buildNumber < 14931 {
|
|
||||||
return terminfo.ColorLevelHundreds, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows 10 build 14931 is the first release that supports 16m/TrueColor
|
|
||||||
debugf("support True Color on windows version is >= build 14931")
|
|
||||||
return terminfo.ColorLevelMillions, true
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* render full color code on windows(8,16,24bit color)
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// docs https://docs.microsoft.com/zh-cn/windows/console/getconsolemode#parameters
|
|
||||||
const (
|
|
||||||
// equals to docs page's ENABLE_VIRTUAL_TERMINAL_PROCESSING 0x0004
|
|
||||||
EnableVirtualTerminalProcessingMode uint32 = 0x4
|
|
||||||
)
|
|
||||||
|
|
||||||
// EnableVirtualTerminalProcessing Enable virtual terminal processing
|
|
||||||
//
|
|
||||||
// ref from github.com/konsorten/go-windows-terminal-sequences
|
|
||||||
// doc https://docs.microsoft.com/zh-cn/windows/console/console-virtual-terminal-sequences#samples
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// err := EnableVirtualTerminalProcessing(syscall.Stdout, true)
|
|
||||||
// // support print color text
|
|
||||||
// err = EnableVirtualTerminalProcessing(syscall.Stdout, false)
|
|
||||||
func EnableVirtualTerminalProcessing(stream syscall.Handle, enable bool) error {
|
|
||||||
var mode uint32
|
|
||||||
// Check if it is currently in the terminal
|
|
||||||
// err := syscall.GetConsoleMode(syscall.Stdout, &mode)
|
|
||||||
err := syscall.GetConsoleMode(stream, &mode)
|
|
||||||
if err != nil {
|
|
||||||
// fmt.Println("EnableVirtualTerminalProcessing", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if enable {
|
|
||||||
mode |= EnableVirtualTerminalProcessingMode
|
|
||||||
} else {
|
|
||||||
mode &^= EnableVirtualTerminalProcessingMode
|
|
||||||
}
|
|
||||||
|
|
||||||
ret, _, err := procSetConsoleMode.Call(uintptr(stream), uintptr(mode))
|
|
||||||
if ret == 0 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderColorCodeOnCmd enable cmd color render.
|
|
||||||
// func renderColorCodeOnCmd(fn func()) {
|
|
||||||
// err := EnableVirtualTerminalProcessing(syscall.Stdout, true)
|
|
||||||
// // if is not in terminal, will clear color tag.
|
|
||||||
// if err != nil {
|
|
||||||
// // panic(err)
|
|
||||||
// fn()
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // force open color render
|
|
||||||
// old := ForceOpenColor()
|
|
||||||
// fn()
|
|
||||||
// // revert color setting
|
|
||||||
// supportColor = old
|
|
||||||
//
|
|
||||||
// err = EnableVirtualTerminalProcessing(syscall.Stdout, false)
|
|
||||||
// if err != nil {
|
|
||||||
// panic(err)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* render simple color code on windows
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// IsTty returns true if the given file descriptor is a terminal.
|
|
||||||
func IsTty(fd uintptr) bool {
|
|
||||||
initKernel32Proc()
|
|
||||||
|
|
||||||
var st uint32
|
|
||||||
r, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, fd, uintptr(unsafe.Pointer(&st)), 0)
|
|
||||||
return r != 0 && e == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsTerminal returns true if the given file descriptor is a terminal.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// fd := os.Stdout.Fd()
|
|
||||||
// fd := uintptr(syscall.Stdout) // for windows
|
|
||||||
// IsTerminal(fd)
|
|
||||||
func IsTerminal(fd uintptr) bool {
|
|
||||||
initKernel32Proc()
|
|
||||||
|
|
||||||
var st uint32
|
|
||||||
r, _, e := syscall.Syscall(procGetConsoleMode.Addr(), 2, fd, uintptr(unsafe.Pointer(&st)), 0)
|
|
||||||
return r != 0 && e == 0
|
|
||||||
}
|
|
||||||
9
vendor/github.com/gookit/color/go.mod
generated
vendored
9
vendor/github.com/gookit/color/go.mod
generated
vendored
@@ -1,9 +0,0 @@
|
|||||||
module github.com/gookit/color
|
|
||||||
|
|
||||||
go 1.12
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/stretchr/testify v1.6.1
|
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778
|
|
||||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44
|
|
||||||
)
|
|
||||||
15
vendor/github.com/gookit/color/go.sum
generated
vendored
15
vendor/github.com/gookit/color/go.sum
generated
vendored
@@ -1,15 +0,0 @@
|
|||||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
|
||||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
|
|
||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
|
||||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c=
|
|
||||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
122
vendor/github.com/gookit/color/printer.go
generated
vendored
122
vendor/github.com/gookit/color/printer.go
generated
vendored
@@ -1,122 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* colored message Printer
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// PrinterFace interface
|
|
||||||
type PrinterFace interface {
|
|
||||||
fmt.Stringer
|
|
||||||
Sprint(a ...interface{}) string
|
|
||||||
Sprintf(format string, a ...interface{}) string
|
|
||||||
Print(a ...interface{})
|
|
||||||
Printf(format string, a ...interface{})
|
|
||||||
Println(a ...interface{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printer a generic color message printer.
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// p := &Printer{Code: "32;45;3"}
|
|
||||||
// p.Print("message")
|
|
||||||
type Printer struct {
|
|
||||||
// NoColor disable color.
|
|
||||||
NoColor bool
|
|
||||||
// Code color code string. eg "32;45;3"
|
|
||||||
Code string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPrinter instance
|
|
||||||
func NewPrinter(colorCode string) *Printer {
|
|
||||||
return &Printer{Code: colorCode}
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns color code string. eg: "32;45;3"
|
|
||||||
func (p *Printer) String() string {
|
|
||||||
// panic("implement me")
|
|
||||||
return p.Code
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint returns rendering colored messages
|
|
||||||
func (p *Printer) Sprint(a ...interface{}) string {
|
|
||||||
return RenderCode(p.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf returns format and rendering colored messages
|
|
||||||
func (p *Printer) Sprintf(format string, a ...interface{}) string {
|
|
||||||
return RenderString(p.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print rendering colored messages
|
|
||||||
func (p *Printer) Print(a ...interface{}) {
|
|
||||||
doPrintV2(p.String(), fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and rendering colored messages
|
|
||||||
func (p *Printer) Printf(format string, a ...interface{}) {
|
|
||||||
doPrintV2(p.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println rendering colored messages with newline
|
|
||||||
func (p *Printer) Println(a ...interface{}) {
|
|
||||||
doPrintlnV2(p.Code, a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty color code
|
|
||||||
func (p *Printer) IsEmpty() bool {
|
|
||||||
return p.Code == ""
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* SimplePrinter struct
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// SimplePrinter use for quick use color print on inject to struct
|
|
||||||
type SimplePrinter struct{}
|
|
||||||
|
|
||||||
// Print message
|
|
||||||
func (s *SimplePrinter) Print(v ...interface{}) {
|
|
||||||
Print(v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf message
|
|
||||||
func (s *SimplePrinter) Printf(format string, v ...interface{}) {
|
|
||||||
Printf(format, v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println message
|
|
||||||
func (s *SimplePrinter) Println(v ...interface{}) {
|
|
||||||
Println(v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infof message
|
|
||||||
func (s *SimplePrinter) Infof(format string, a ...interface{}) {
|
|
||||||
Info.Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infoln message
|
|
||||||
func (s *SimplePrinter) Infoln(a ...interface{}) {
|
|
||||||
Info.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnf message
|
|
||||||
func (s *SimplePrinter) Warnf(format string, a ...interface{}) {
|
|
||||||
Warn.Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnln message
|
|
||||||
func (s *SimplePrinter) Warnln(a ...interface{}) {
|
|
||||||
Warn.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errorf message
|
|
||||||
func (s *SimplePrinter) Errorf(format string, a ...interface{}) {
|
|
||||||
Error.Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errorln message
|
|
||||||
func (s *SimplePrinter) Errorln(a ...interface{}) {
|
|
||||||
Error.Println(a...)
|
|
||||||
}
|
|
||||||
109
vendor/github.com/gookit/color/quickstart.go
generated
vendored
109
vendor/github.com/gookit/color/quickstart.go
generated
vendored
@@ -1,109 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* quick use color print message
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Redp print message with Red color
|
|
||||||
func Redp(a ...interface{}) {
|
|
||||||
Red.Print(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redln print message line with Red color
|
|
||||||
func Redln(a ...interface{}) {
|
|
||||||
Red.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bluep print message with Blue color
|
|
||||||
func Bluep(a ...interface{}) {
|
|
||||||
Blue.Print(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blueln print message line with Blue color
|
|
||||||
func Blueln(a ...interface{}) {
|
|
||||||
Blue.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cyanp print message with Cyan color
|
|
||||||
func Cyanp(a ...interface{}) {
|
|
||||||
Cyan.Print(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cyanln print message line with Cyan color
|
|
||||||
func Cyanln(a ...interface{}) {
|
|
||||||
Cyan.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grayp print message with Gray color
|
|
||||||
func Grayp(a ...interface{}) {
|
|
||||||
Gray.Print(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grayln print message line with Gray color
|
|
||||||
func Grayln(a ...interface{}) {
|
|
||||||
Gray.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Greenp print message with Green color
|
|
||||||
func Greenp(a ...interface{}) {
|
|
||||||
Green.Print(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Greenln print message line with Green color
|
|
||||||
func Greenln(a ...interface{}) {
|
|
||||||
Green.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yellowp print message with Yellow color
|
|
||||||
func Yellowp(a ...interface{}) {
|
|
||||||
Yellow.Print(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Yellowln print message line with Yellow color
|
|
||||||
func Yellowln(a ...interface{}) {
|
|
||||||
Yellow.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magentap print message with Magenta color
|
|
||||||
func Magentap(a ...interface{}) {
|
|
||||||
Magenta.Print(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Magentaln print message line with Magenta color
|
|
||||||
func Magentaln(a ...interface{}) {
|
|
||||||
Magenta.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* quick use style print message
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Infof print message with Info style
|
|
||||||
func Infof(format string, a ...interface{}) {
|
|
||||||
Info.Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infoln print message with Info style
|
|
||||||
func Infoln(a ...interface{}) {
|
|
||||||
Info.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errorf print message with Error style
|
|
||||||
func Errorf(format string, a ...interface{}) {
|
|
||||||
Error.Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errorln print message with Error style
|
|
||||||
func Errorln(a ...interface{}) {
|
|
||||||
Error.Println(a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnf print message with Warn style
|
|
||||||
func Warnf(format string, a ...interface{}) {
|
|
||||||
Warn.Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnln print message with Warn style
|
|
||||||
func Warnln(a ...interface{}) {
|
|
||||||
Warn.Println(a...)
|
|
||||||
}
|
|
||||||
315
vendor/github.com/gookit/color/style.go
generated
vendored
315
vendor/github.com/gookit/color/style.go
generated
vendored
@@ -1,315 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* 16 color Style
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Style a 16 color style. can add: fg color, bg color, color options
|
|
||||||
//
|
|
||||||
// Example:
|
|
||||||
// color.Style{color.FgGreen}.Print("message")
|
|
||||||
type Style []Color
|
|
||||||
|
|
||||||
// New create a custom style
|
|
||||||
//
|
|
||||||
// Usage:
|
|
||||||
// color.New(color.FgGreen).Print("message")
|
|
||||||
// equals to:
|
|
||||||
// color.Style{color.FgGreen}.Print("message")
|
|
||||||
func New(colors ...Color) Style {
|
|
||||||
return colors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to global styles map
|
|
||||||
func (s Style) Save(name string) {
|
|
||||||
AddStyle(name, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to global styles map
|
|
||||||
func (s *Style) Add(cs ...Color) {
|
|
||||||
*s = append(*s, cs...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render render text
|
|
||||||
// Usage:
|
|
||||||
// color.New(color.FgGreen).Render("text")
|
|
||||||
// color.New(color.FgGreen, color.BgBlack, color.OpBold).Render("text")
|
|
||||||
func (s Style) Render(a ...interface{}) string {
|
|
||||||
return RenderCode(s.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Renderln render text line.
|
|
||||||
// like Println, will add spaces for each argument
|
|
||||||
// Usage:
|
|
||||||
// color.New(color.FgGreen).Renderln("text", "more")
|
|
||||||
// color.New(color.FgGreen, color.BgBlack, color.OpBold).Render("text", "more")
|
|
||||||
func (s Style) Renderln(a ...interface{}) string {
|
|
||||||
return RenderWithSpaces(s.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint is alias of the 'Render'
|
|
||||||
func (s Style) Sprint(a ...interface{}) string {
|
|
||||||
return RenderCode(s.String(), a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf format and render message.
|
|
||||||
func (s Style) Sprintf(format string, a ...interface{}) string {
|
|
||||||
return RenderString(s.String(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print render and Print text
|
|
||||||
func (s Style) Print(a ...interface{}) {
|
|
||||||
doPrintV2(s.String(), fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf render and print text
|
|
||||||
func (s Style) Printf(format string, a ...interface{}) {
|
|
||||||
doPrintV2(s.Code(), fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println render and print text line
|
|
||||||
func (s Style) Println(a ...interface{}) {
|
|
||||||
doPrintlnV2(s.String(), a)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Code convert to code string. returns like "32;45;3"
|
|
||||||
func (s Style) Code() string {
|
|
||||||
return s.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// String convert to code string. returns like "32;45;3"
|
|
||||||
func (s Style) String() string {
|
|
||||||
return Colors2code(s...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEmpty style
|
|
||||||
func (s Style) IsEmpty() bool {
|
|
||||||
return len(s) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* Theme(extended Style)
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Theme definition. extends from Style
|
|
||||||
type Theme struct {
|
|
||||||
// Name theme name
|
|
||||||
Name string
|
|
||||||
// Style for the theme
|
|
||||||
Style
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTheme instance
|
|
||||||
func NewTheme(name string, style Style) *Theme {
|
|
||||||
return &Theme{name, style}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to themes map
|
|
||||||
func (t *Theme) Save() {
|
|
||||||
AddTheme(t.Name, t.Style)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tips use name as title, only apply style for name
|
|
||||||
func (t *Theme) Tips(format string, a ...interface{}) {
|
|
||||||
// only apply style for name
|
|
||||||
t.Print(strings.ToUpper(t.Name) + ": ")
|
|
||||||
Printf(format+"\n", a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt use name as title, and apply style for message
|
|
||||||
func (t *Theme) Prompt(format string, a ...interface{}) {
|
|
||||||
title := strings.ToUpper(t.Name) + ":"
|
|
||||||
t.Println(title, fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block like Prompt, but will wrap a empty line
|
|
||||||
func (t *Theme) Block(format string, a ...interface{}) {
|
|
||||||
title := strings.ToUpper(t.Name) + ":\n"
|
|
||||||
|
|
||||||
t.Println(title, fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* Theme: internal themes
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// internal themes(like bootstrap style)
|
|
||||||
// Usage:
|
|
||||||
// color.Info.Print("message")
|
|
||||||
// color.Info.Printf("a %s message", "test")
|
|
||||||
// color.Warn.Println("message")
|
|
||||||
// color.Error.Println("message")
|
|
||||||
var (
|
|
||||||
// Info color style
|
|
||||||
Info = &Theme{"info", Style{OpReset, FgGreen}}
|
|
||||||
// Note color style
|
|
||||||
Note = &Theme{"note", Style{OpBold, FgLightCyan}}
|
|
||||||
// Warn color style
|
|
||||||
Warn = &Theme{"warning", Style{OpBold, FgYellow}}
|
|
||||||
// Light color style
|
|
||||||
Light = &Theme{"light", Style{FgLightWhite, BgBlack}}
|
|
||||||
// Error color style
|
|
||||||
Error = &Theme{"error", Style{FgLightWhite, BgRed}}
|
|
||||||
// Danger color style
|
|
||||||
Danger = &Theme{"danger", Style{OpBold, FgRed}}
|
|
||||||
// Debug color style
|
|
||||||
Debug = &Theme{"debug", Style{OpReset, FgCyan}}
|
|
||||||
// Notice color style
|
|
||||||
Notice = &Theme{"notice", Style{OpBold, FgCyan}}
|
|
||||||
// Comment color style
|
|
||||||
Comment = &Theme{"comment", Style{OpReset, FgYellow}}
|
|
||||||
// Success color style
|
|
||||||
Success = &Theme{"success", Style{OpBold, FgGreen}}
|
|
||||||
// Primary color style
|
|
||||||
Primary = &Theme{"primary", Style{OpReset, FgBlue}}
|
|
||||||
// Question color style
|
|
||||||
Question = &Theme{"question", Style{OpReset, FgMagenta}}
|
|
||||||
// Secondary color style
|
|
||||||
Secondary = &Theme{"secondary", Style{FgDarkGray}}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Themes internal defined themes.
|
|
||||||
// Usage:
|
|
||||||
// color.Themes["info"].Println("message")
|
|
||||||
var Themes = map[string]*Theme{
|
|
||||||
"info": Info,
|
|
||||||
"note": Note,
|
|
||||||
"light": Light,
|
|
||||||
"error": Error,
|
|
||||||
|
|
||||||
"debug": Debug,
|
|
||||||
"danger": Danger,
|
|
||||||
"notice": Notice,
|
|
||||||
"success": Success,
|
|
||||||
"comment": Comment,
|
|
||||||
"primary": Primary,
|
|
||||||
"warning": Warn,
|
|
||||||
|
|
||||||
"question": Question,
|
|
||||||
"secondary": Secondary,
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTheme add a theme and style
|
|
||||||
func AddTheme(name string, style Style) {
|
|
||||||
Themes[name] = NewTheme(name, style)
|
|
||||||
Styles[name] = style
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTheme get defined theme by name
|
|
||||||
func GetTheme(name string) *Theme {
|
|
||||||
return Themes[name]
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* internal styles
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Styles internal defined styles, like bootstrap styles.
|
|
||||||
// Usage:
|
|
||||||
// color.Styles["info"].Println("message")
|
|
||||||
var Styles = map[string]Style{
|
|
||||||
"info": {OpReset, FgGreen},
|
|
||||||
"note": {OpBold, FgLightCyan},
|
|
||||||
"light": {FgLightWhite, BgRed},
|
|
||||||
"error": {FgLightWhite, BgRed},
|
|
||||||
|
|
||||||
"danger": {OpBold, FgRed},
|
|
||||||
"notice": {OpBold, FgCyan},
|
|
||||||
"success": {OpBold, FgGreen},
|
|
||||||
"comment": {OpReset, FgMagenta},
|
|
||||||
"primary": {OpReset, FgBlue},
|
|
||||||
"warning": {OpBold, FgYellow},
|
|
||||||
|
|
||||||
"question": {OpReset, FgMagenta},
|
|
||||||
"secondary": {FgDarkGray},
|
|
||||||
}
|
|
||||||
|
|
||||||
// some style name alias
|
|
||||||
var styleAliases = map[string]string{
|
|
||||||
"err": "error",
|
|
||||||
"suc": "success",
|
|
||||||
"warn": "warning",
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddStyle add a style
|
|
||||||
func AddStyle(name string, s Style) {
|
|
||||||
Styles[name] = s
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStyle get defined style by name
|
|
||||||
func GetStyle(name string) Style {
|
|
||||||
if s, ok := Styles[name]; ok {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
if realName, ok := styleAliases[name]; ok {
|
|
||||||
return Styles[realName]
|
|
||||||
}
|
|
||||||
|
|
||||||
// empty style
|
|
||||||
return New()
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* color scheme
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Scheme struct
|
|
||||||
type Scheme struct {
|
|
||||||
Name string
|
|
||||||
Styles map[string]Style
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewScheme create new Scheme
|
|
||||||
func NewScheme(name string, styles map[string]Style) *Scheme {
|
|
||||||
return &Scheme{Name: name, Styles: styles}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDefaultScheme create an defuault color Scheme
|
|
||||||
func NewDefaultScheme(name string) *Scheme {
|
|
||||||
return NewScheme(name, map[string]Style{
|
|
||||||
"info": {OpReset, FgGreen},
|
|
||||||
"warn": {OpBold, FgYellow},
|
|
||||||
"error": {FgLightWhite, BgRed},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Style get by name
|
|
||||||
func (s *Scheme) Style(name string) Style {
|
|
||||||
return s.Styles[name]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infof message print
|
|
||||||
func (s *Scheme) Infof(format string, a ...interface{}) {
|
|
||||||
s.Styles["info"].Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Infoln message print
|
|
||||||
func (s *Scheme) Infoln(v ...interface{}) {
|
|
||||||
s.Styles["info"].Println(v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnf message print
|
|
||||||
func (s *Scheme) Warnf(format string, a ...interface{}) {
|
|
||||||
s.Styles["warn"].Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnln message print
|
|
||||||
func (s *Scheme) Warnln(v ...interface{}) {
|
|
||||||
s.Styles["warn"].Println(v...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errorf message print
|
|
||||||
func (s *Scheme) Errorf(format string, a ...interface{}) {
|
|
||||||
s.Styles["error"].Printf(format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Errorln message print
|
|
||||||
func (s *Scheme) Errorln(v ...interface{}) {
|
|
||||||
s.Styles["error"].Println(v...)
|
|
||||||
}
|
|
||||||
206
vendor/github.com/gookit/color/utils.go
generated
vendored
206
vendor/github.com/gookit/color/utils.go
generated
vendored
@@ -1,206 +0,0 @@
|
|||||||
package color
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SetTerminal by given code.
|
|
||||||
func SetTerminal(code string) error {
|
|
||||||
if !Enable || !SupportColor() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fmt.Fprintf(output, SettingTpl, code)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResetTerminal terminal setting.
|
|
||||||
func ResetTerminal() error {
|
|
||||||
if !Enable || !SupportColor() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := fmt.Fprint(output, ResetSet)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* print methods(will auto parse color tags)
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// Print render color tag and print messages
|
|
||||||
func Print(a ...interface{}) {
|
|
||||||
Fprint(output, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Printf format and print messages
|
|
||||||
func Printf(format string, a ...interface{}) {
|
|
||||||
Fprintf(output, format, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Println messages with new line
|
|
||||||
func Println(a ...interface{}) {
|
|
||||||
Fprintln(output, a...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fprint print rendered messages to writer
|
|
||||||
// Notice: will ignore print error
|
|
||||||
func Fprint(w io.Writer, a ...interface{}) {
|
|
||||||
_, err := fmt.Fprint(w, Render(a...))
|
|
||||||
saveInternalError(err)
|
|
||||||
|
|
||||||
// if isLikeInCmd {
|
|
||||||
// renderColorCodeOnCmd(func() {
|
|
||||||
// _, _ = fmt.Fprint(w, Render(a...))
|
|
||||||
// })
|
|
||||||
// } else {
|
|
||||||
// _, _ = fmt.Fprint(w, Render(a...))
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fprintf print format and rendered messages to writer.
|
|
||||||
// Notice: will ignore print error
|
|
||||||
func Fprintf(w io.Writer, format string, a ...interface{}) {
|
|
||||||
str := fmt.Sprintf(format, a...)
|
|
||||||
_, err := fmt.Fprint(w, ReplaceTag(str))
|
|
||||||
saveInternalError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fprintln print rendered messages line to writer
|
|
||||||
// Notice: will ignore print error
|
|
||||||
func Fprintln(w io.Writer, a ...interface{}) {
|
|
||||||
str := formatArgsForPrintln(a)
|
|
||||||
_, err := fmt.Fprintln(w, ReplaceTag(str))
|
|
||||||
saveInternalError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lprint passes colored messages to a log.Logger for printing.
|
|
||||||
// Notice: should be goroutine safe
|
|
||||||
func Lprint(l *log.Logger, a ...interface{}) {
|
|
||||||
l.Print(Render(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render parse color tags, return rendered string.
|
|
||||||
// Usage:
|
|
||||||
// text := Render("<info>hello</> <cyan>world</>!")
|
|
||||||
// fmt.Println(text)
|
|
||||||
func Render(a ...interface{}) string {
|
|
||||||
if len(a) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReplaceTag(fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprint parse color tags, return rendered string
|
|
||||||
func Sprint(a ...interface{}) string {
|
|
||||||
if len(a) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReplaceTag(fmt.Sprint(a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sprintf format and return rendered string
|
|
||||||
func Sprintf(format string, a ...interface{}) string {
|
|
||||||
return ReplaceTag(fmt.Sprintf(format, a...))
|
|
||||||
}
|
|
||||||
|
|
||||||
// String alias of the ReplaceTag
|
|
||||||
func String(s string) string {
|
|
||||||
return ReplaceTag(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Text alias of the ReplaceTag
|
|
||||||
func Text(s string) string {
|
|
||||||
return ReplaceTag(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* helper methods for print
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// new implementation, support render full color code on pwsh.exe, cmd.exe
|
|
||||||
func doPrintV2(code, str string) {
|
|
||||||
_, err := fmt.Fprint(output, RenderString(code, str))
|
|
||||||
saveInternalError(err)
|
|
||||||
|
|
||||||
// if isLikeInCmd {
|
|
||||||
// renderColorCodeOnCmd(func() {
|
|
||||||
// _, _ = fmt.Fprint(output, RenderString(code, str))
|
|
||||||
// })
|
|
||||||
// } else {
|
|
||||||
// _, _ = fmt.Fprint(output, RenderString(code, str))
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
// new implementation, support render full color code on pwsh.exe, cmd.exe
|
|
||||||
func doPrintlnV2(code string, args []interface{}) {
|
|
||||||
str := formatArgsForPrintln(args)
|
|
||||||
_, err := fmt.Fprintln(output, RenderString(code, str))
|
|
||||||
saveInternalError(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if use Println, will add spaces for each arg
|
|
||||||
func formatArgsForPrintln(args []interface{}) (message string) {
|
|
||||||
if ln := len(args); ln == 0 {
|
|
||||||
message = ""
|
|
||||||
} else if ln == 1 {
|
|
||||||
message = fmt.Sprint(args[0])
|
|
||||||
} else {
|
|
||||||
message = fmt.Sprintln(args...)
|
|
||||||
// clear last "\n"
|
|
||||||
message = message[:len(message)-1]
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
/*************************************************************
|
|
||||||
* helper methods
|
|
||||||
*************************************************************/
|
|
||||||
|
|
||||||
// is on debug mode
|
|
||||||
// func isDebugMode() bool {
|
|
||||||
// return debugMode == "on"
|
|
||||||
// }
|
|
||||||
|
|
||||||
func debugf(f string, v ...interface{}) {
|
|
||||||
if debugMode {
|
|
||||||
fmt.Print("COLOR_DEBUG: ")
|
|
||||||
fmt.Printf(f, v...)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// equals: return ok ? val1 : val2
|
|
||||||
func compareVal(ok bool, val1, val2 uint8) uint8 {
|
|
||||||
if ok {
|
|
||||||
return val1
|
|
||||||
}
|
|
||||||
return val2
|
|
||||||
}
|
|
||||||
|
|
||||||
func saveInternalError(err error) {
|
|
||||||
if err != nil {
|
|
||||||
debugf("inner error: %s", err.Error())
|
|
||||||
innerErrs = append(innerErrs, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringToArr(str, sep string) (arr []string) {
|
|
||||||
str = strings.TrimSpace(str)
|
|
||||||
if str == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ss := strings.Split(str, sep)
|
|
||||||
for _, val := range ss {
|
|
||||||
if val = strings.TrimSpace(val); val != "" {
|
|
||||||
arr = append(arr, val)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
16
vendor/github.com/mattn/go-runewidth/.travis.yml
generated
vendored
16
vendor/github.com/mattn/go-runewidth/.travis.yml
generated
vendored
@@ -1,16 +0,0 @@
|
|||||||
language: go
|
|
||||||
sudo: false
|
|
||||||
go:
|
|
||||||
- 1.13.x
|
|
||||||
- tip
|
|
||||||
|
|
||||||
before_install:
|
|
||||||
- go get -t -v ./...
|
|
||||||
|
|
||||||
script:
|
|
||||||
- go generate
|
|
||||||
- git diff --cached --exit-code
|
|
||||||
- ./go.test.sh
|
|
||||||
|
|
||||||
after_success:
|
|
||||||
- bash <(curl -s https://codecov.io/bash)
|
|
||||||
21
vendor/github.com/mattn/go-runewidth/LICENSE
generated
vendored
21
vendor/github.com/mattn/go-runewidth/LICENSE
generated
vendored
@@ -1,21 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
|
|
||||||
Copyright (c) 2016 Yasuhiro Matsumoto
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
27
vendor/github.com/mattn/go-runewidth/README.md
generated
vendored
27
vendor/github.com/mattn/go-runewidth/README.md
generated
vendored
@@ -1,27 +0,0 @@
|
|||||||
go-runewidth
|
|
||||||
============
|
|
||||||
|
|
||||||
[](https://travis-ci.org/mattn/go-runewidth)
|
|
||||||
[](https://codecov.io/gh/mattn/go-runewidth)
|
|
||||||
[](http://godoc.org/github.com/mattn/go-runewidth)
|
|
||||||
[](https://goreportcard.com/report/github.com/mattn/go-runewidth)
|
|
||||||
|
|
||||||
Provides functions to get fixed width of the character or string.
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
|
|
||||||
```go
|
|
||||||
runewidth.StringWidth("つのだ☆HIRO") == 12
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
Author
|
|
||||||
------
|
|
||||||
|
|
||||||
Yasuhiro Matsumoto
|
|
||||||
|
|
||||||
License
|
|
||||||
-------
|
|
||||||
|
|
||||||
under the MIT License: http://mattn.mit-license.org/2013
|
|
||||||
5
vendor/github.com/mattn/go-runewidth/go.mod
generated
vendored
5
vendor/github.com/mattn/go-runewidth/go.mod
generated
vendored
@@ -1,5 +0,0 @@
|
|||||||
module github.com/mattn/go-runewidth
|
|
||||||
|
|
||||||
go 1.9
|
|
||||||
|
|
||||||
require github.com/rivo/uniseg v0.2.0
|
|
||||||
2
vendor/github.com/mattn/go-runewidth/go.sum
generated
vendored
2
vendor/github.com/mattn/go-runewidth/go.sum
generated
vendored
@@ -1,2 +0,0 @@
|
|||||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
|
||||||
12
vendor/github.com/mattn/go-runewidth/go.test.sh
generated
vendored
12
vendor/github.com/mattn/go-runewidth/go.test.sh
generated
vendored
@@ -1,12 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
echo "" > coverage.txt
|
|
||||||
|
|
||||||
for d in $(go list ./... | grep -v vendor); do
|
|
||||||
go test -race -coverprofile=profile.out -covermode=atomic "$d"
|
|
||||||
if [ -f profile.out ]; then
|
|
||||||
cat profile.out >> coverage.txt
|
|
||||||
rm profile.out
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
273
vendor/github.com/mattn/go-runewidth/runewidth.go
generated
vendored
273
vendor/github.com/mattn/go-runewidth/runewidth.go
generated
vendored
@@ -1,273 +0,0 @@
|
|||||||
package runewidth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/rivo/uniseg"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:generate go run script/generate.go
|
|
||||||
|
|
||||||
var (
|
|
||||||
// EastAsianWidth will be set true if the current locale is CJK
|
|
||||||
EastAsianWidth bool
|
|
||||||
|
|
||||||
// StrictEmojiNeutral should be set false if handle broken fonts
|
|
||||||
StrictEmojiNeutral bool = true
|
|
||||||
|
|
||||||
// DefaultCondition is a condition in current locale
|
|
||||||
DefaultCondition = &Condition{
|
|
||||||
EastAsianWidth: false,
|
|
||||||
StrictEmojiNeutral: true,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
handleEnv()
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleEnv() {
|
|
||||||
env := os.Getenv("RUNEWIDTH_EASTASIAN")
|
|
||||||
if env == "" {
|
|
||||||
EastAsianWidth = IsEastAsian()
|
|
||||||
} else {
|
|
||||||
EastAsianWidth = env == "1"
|
|
||||||
}
|
|
||||||
// update DefaultCondition
|
|
||||||
DefaultCondition.EastAsianWidth = EastAsianWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
type interval struct {
|
|
||||||
first rune
|
|
||||||
last rune
|
|
||||||
}
|
|
||||||
|
|
||||||
type table []interval
|
|
||||||
|
|
||||||
func inTables(r rune, ts ...table) bool {
|
|
||||||
for _, t := range ts {
|
|
||||||
if inTable(r, t) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func inTable(r rune, t table) bool {
|
|
||||||
if r < t[0].first {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
bot := 0
|
|
||||||
top := len(t) - 1
|
|
||||||
for top >= bot {
|
|
||||||
mid := (bot + top) >> 1
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case t[mid].last < r:
|
|
||||||
bot = mid + 1
|
|
||||||
case t[mid].first > r:
|
|
||||||
top = mid - 1
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var private = table{
|
|
||||||
{0x00E000, 0x00F8FF}, {0x0F0000, 0x0FFFFD}, {0x100000, 0x10FFFD},
|
|
||||||
}
|
|
||||||
|
|
||||||
var nonprint = table{
|
|
||||||
{0x0000, 0x001F}, {0x007F, 0x009F}, {0x00AD, 0x00AD},
|
|
||||||
{0x070F, 0x070F}, {0x180B, 0x180E}, {0x200B, 0x200F},
|
|
||||||
{0x2028, 0x202E}, {0x206A, 0x206F}, {0xD800, 0xDFFF},
|
|
||||||
{0xFEFF, 0xFEFF}, {0xFFF9, 0xFFFB}, {0xFFFE, 0xFFFF},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Condition have flag EastAsianWidth whether the current locale is CJK or not.
|
|
||||||
type Condition struct {
|
|
||||||
EastAsianWidth bool
|
|
||||||
StrictEmojiNeutral bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCondition return new instance of Condition which is current locale.
|
|
||||||
func NewCondition() *Condition {
|
|
||||||
return &Condition{
|
|
||||||
EastAsianWidth: EastAsianWidth,
|
|
||||||
StrictEmojiNeutral: StrictEmojiNeutral,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuneWidth returns the number of cells in r.
|
|
||||||
// See http://www.unicode.org/reports/tr11/
|
|
||||||
func (c *Condition) RuneWidth(r rune) int {
|
|
||||||
// optimized version, verified by TestRuneWidthChecksums()
|
|
||||||
if !c.EastAsianWidth {
|
|
||||||
switch {
|
|
||||||
case r < 0x20 || r > 0x10FFFF:
|
|
||||||
return 0
|
|
||||||
case (r >= 0x7F && r <= 0x9F) || r == 0xAD: // nonprint
|
|
||||||
return 0
|
|
||||||
case r < 0x300:
|
|
||||||
return 1
|
|
||||||
case inTable(r, narrow):
|
|
||||||
return 1
|
|
||||||
case inTables(r, nonprint, combining):
|
|
||||||
return 0
|
|
||||||
case inTable(r, doublewidth):
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
switch {
|
|
||||||
case r < 0 || r > 0x10FFFF || inTables(r, nonprint, combining):
|
|
||||||
return 0
|
|
||||||
case inTable(r, narrow):
|
|
||||||
return 1
|
|
||||||
case inTables(r, ambiguous, doublewidth):
|
|
||||||
return 2
|
|
||||||
case !c.StrictEmojiNeutral && inTables(r, ambiguous, emoji, narrow):
|
|
||||||
return 2
|
|
||||||
default:
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StringWidth return width as you can see
|
|
||||||
func (c *Condition) StringWidth(s string) (width int) {
|
|
||||||
g := uniseg.NewGraphemes(s)
|
|
||||||
for g.Next() {
|
|
||||||
var chWidth int
|
|
||||||
for _, r := range g.Runes() {
|
|
||||||
chWidth = c.RuneWidth(r)
|
|
||||||
if chWidth > 0 {
|
|
||||||
break // Our best guess at this point is to use the width of the first non-zero-width rune.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
width += chWidth
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate return string truncated with w cells
|
|
||||||
func (c *Condition) Truncate(s string, w int, tail string) string {
|
|
||||||
if c.StringWidth(s) <= w {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
w -= c.StringWidth(tail)
|
|
||||||
var width int
|
|
||||||
pos := len(s)
|
|
||||||
g := uniseg.NewGraphemes(s)
|
|
||||||
for g.Next() {
|
|
||||||
var chWidth int
|
|
||||||
for _, r := range g.Runes() {
|
|
||||||
chWidth = c.RuneWidth(r)
|
|
||||||
if chWidth > 0 {
|
|
||||||
break // See StringWidth() for details.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if width+chWidth > w {
|
|
||||||
pos, _ = g.Positions()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
width += chWidth
|
|
||||||
}
|
|
||||||
return s[:pos] + tail
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap return string wrapped with w cells
|
|
||||||
func (c *Condition) Wrap(s string, w int) string {
|
|
||||||
width := 0
|
|
||||||
out := ""
|
|
||||||
for _, r := range []rune(s) {
|
|
||||||
cw := c.RuneWidth(r)
|
|
||||||
if r == '\n' {
|
|
||||||
out += string(r)
|
|
||||||
width = 0
|
|
||||||
continue
|
|
||||||
} else if width+cw > w {
|
|
||||||
out += "\n"
|
|
||||||
width = 0
|
|
||||||
out += string(r)
|
|
||||||
width += cw
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out += string(r)
|
|
||||||
width += cw
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// FillLeft return string filled in left by spaces in w cells
|
|
||||||
func (c *Condition) FillLeft(s string, w int) string {
|
|
||||||
width := c.StringWidth(s)
|
|
||||||
count := w - width
|
|
||||||
if count > 0 {
|
|
||||||
b := make([]byte, count)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = ' '
|
|
||||||
}
|
|
||||||
return string(b) + s
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// FillRight return string filled in left by spaces in w cells
|
|
||||||
func (c *Condition) FillRight(s string, w int) string {
|
|
||||||
width := c.StringWidth(s)
|
|
||||||
count := w - width
|
|
||||||
if count > 0 {
|
|
||||||
b := make([]byte, count)
|
|
||||||
for i := range b {
|
|
||||||
b[i] = ' '
|
|
||||||
}
|
|
||||||
return s + string(b)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// RuneWidth returns the number of cells in r.
|
|
||||||
// See http://www.unicode.org/reports/tr11/
|
|
||||||
func RuneWidth(r rune) int {
|
|
||||||
return DefaultCondition.RuneWidth(r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAmbiguousWidth returns whether is ambiguous width or not.
|
|
||||||
func IsAmbiguousWidth(r rune) bool {
|
|
||||||
return inTables(r, private, ambiguous)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsNeutralWidth returns whether is neutral width or not.
|
|
||||||
func IsNeutralWidth(r rune) bool {
|
|
||||||
return inTable(r, neutral)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StringWidth return width as you can see
|
|
||||||
func StringWidth(s string) (width int) {
|
|
||||||
return DefaultCondition.StringWidth(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate return string truncated with w cells
|
|
||||||
func Truncate(s string, w int, tail string) string {
|
|
||||||
return DefaultCondition.Truncate(s, w, tail)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap return string wrapped with w cells
|
|
||||||
func Wrap(s string, w int) string {
|
|
||||||
return DefaultCondition.Wrap(s, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FillLeft return string filled in left by spaces in w cells
|
|
||||||
func FillLeft(s string, w int) string {
|
|
||||||
return DefaultCondition.FillLeft(s, w)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FillRight return string filled in left by spaces in w cells
|
|
||||||
func FillRight(s string, w int) string {
|
|
||||||
return DefaultCondition.FillRight(s, w)
|
|
||||||
}
|
|
||||||
8
vendor/github.com/mattn/go-runewidth/runewidth_appengine.go
generated
vendored
8
vendor/github.com/mattn/go-runewidth/runewidth_appengine.go
generated
vendored
@@ -1,8 +0,0 @@
|
|||||||
// +build appengine
|
|
||||||
|
|
||||||
package runewidth
|
|
||||||
|
|
||||||
// IsEastAsian return true if the current locale is CJK
|
|
||||||
func IsEastAsian() bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
9
vendor/github.com/mattn/go-runewidth/runewidth_js.go
generated
vendored
9
vendor/github.com/mattn/go-runewidth/runewidth_js.go
generated
vendored
@@ -1,9 +0,0 @@
|
|||||||
// +build js
|
|
||||||
// +build !appengine
|
|
||||||
|
|
||||||
package runewidth
|
|
||||||
|
|
||||||
func IsEastAsian() bool {
|
|
||||||
// TODO: Implement this for the web. Detect east asian in a compatible way, and return true.
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
82
vendor/github.com/mattn/go-runewidth/runewidth_posix.go
generated
vendored
82
vendor/github.com/mattn/go-runewidth/runewidth_posix.go
generated
vendored
@@ -1,82 +0,0 @@
|
|||||||
// +build !windows
|
|
||||||
// +build !js
|
|
||||||
// +build !appengine
|
|
||||||
|
|
||||||
package runewidth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
var reLoc = regexp.MustCompile(`^[a-z][a-z][a-z]?(?:_[A-Z][A-Z])?\.(.+)`)
|
|
||||||
|
|
||||||
var mblenTable = map[string]int{
|
|
||||||
"utf-8": 6,
|
|
||||||
"utf8": 6,
|
|
||||||
"jis": 8,
|
|
||||||
"eucjp": 3,
|
|
||||||
"euckr": 2,
|
|
||||||
"euccn": 2,
|
|
||||||
"sjis": 2,
|
|
||||||
"cp932": 2,
|
|
||||||
"cp51932": 2,
|
|
||||||
"cp936": 2,
|
|
||||||
"cp949": 2,
|
|
||||||
"cp950": 2,
|
|
||||||
"big5": 2,
|
|
||||||
"gbk": 2,
|
|
||||||
"gb2312": 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
func isEastAsian(locale string) bool {
|
|
||||||
charset := strings.ToLower(locale)
|
|
||||||
r := reLoc.FindStringSubmatch(locale)
|
|
||||||
if len(r) == 2 {
|
|
||||||
charset = strings.ToLower(r[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
if strings.HasSuffix(charset, "@cjk_narrow") {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
for pos, b := range []byte(charset) {
|
|
||||||
if b == '@' {
|
|
||||||
charset = charset[:pos]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
max := 1
|
|
||||||
if m, ok := mblenTable[charset]; ok {
|
|
||||||
max = m
|
|
||||||
}
|
|
||||||
if max > 1 && (charset[0] != 'u' ||
|
|
||||||
strings.HasPrefix(locale, "ja") ||
|
|
||||||
strings.HasPrefix(locale, "ko") ||
|
|
||||||
strings.HasPrefix(locale, "zh")) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsEastAsian return true if the current locale is CJK
|
|
||||||
func IsEastAsian() bool {
|
|
||||||
locale := os.Getenv("LC_ALL")
|
|
||||||
if locale == "" {
|
|
||||||
locale = os.Getenv("LC_CTYPE")
|
|
||||||
}
|
|
||||||
if locale == "" {
|
|
||||||
locale = os.Getenv("LANG")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore C locale
|
|
||||||
if locale == "POSIX" || locale == "C" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if len(locale) > 1 && locale[0] == 'C' && (locale[1] == '.' || locale[1] == '-') {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return isEastAsian(locale)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user