Skip to content

Commit 7ceb44a

Browse files
authored
tle: add -m metadata flag (#94)
* tle: add -m flag to show metadata; support INPUT parsing and timestamp estimation * fix: remove unused variables and function to satisfy staticcheck * refactor: use armor.NewReader directly instead of manual armor parsing * test: add TestMetadata to validate metadata extraction from TLE files
1 parent 962ec50 commit 7ceb44a

File tree

4 files changed

+171
-2
lines changed

4 files changed

+171
-2
lines changed

cmd/tle/commands/commands.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ Example:
5959
$ tle -D 10d -o encrypted_file data_to_encrypt
6060
6161
After the specified duration:
62-
$ tle -d -o decrypted_file.txt encrypted_file`
62+
$ tle -d -o decrypted_file.txt encrypted_file
63+
64+
Metadata examples:
65+
$ tle -m # Prints network metadata (YAML)
66+
$ tle -m encrypted.age # Prints ciphertext metadata (round, chain, time)`
6367

6468
// PrintUsage displays the usage information.
6569
func PrintUsage(log *log.Logger) {

cmd/tle/commands/commands_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package commands
33
import (
44
"bytes"
55
"os"
6+
"path/filepath"
67
"testing"
78
"time"
89

10+
"github.com/drand/tlock/networks/http"
911
"github.com/stretchr/testify/require"
12+
"gopkg.in/yaml.v3"
1013
)
1114

1215
func TestParseDuration(t *testing.T) {
@@ -148,3 +151,44 @@ func TestEncryptionWithDurationOverflowUsingOtherUnits(t *testing.T) {
148151
err := Encrypt(flags, os.Stdout, bytes.NewBufferString("very nice"), nil)
149152
require.ErrorIs(t, err, ErrInvalidDurationValue)
150153
}
154+
155+
func TestMetadata(t *testing.T) {
156+
if testing.Short() {
157+
t.Skip("skipping network test in short mode")
158+
}
159+
160+
// use testnet quicknet-t network matching the testdata file
161+
testnetHost := "http://pl-eu.testnet.drand.sh"
162+
testnetQuicknetT := "cc9c398442737cbd141526600919edd69f1d6f9b4adb67e4d912fbc64341a9a5"
163+
164+
network, err := http.NewNetwork(testnetHost, testnetQuicknetT)
165+
require.NoError(t, err)
166+
167+
// open the testdata file
168+
testdataPath := filepath.Join("..", "..", "..", "testdata", "lorem-tle-testnet-quicknet-t-2024-01-17-15-28.tle")
169+
f, err := os.Open(testdataPath)
170+
require.NoError(t, err)
171+
defer f.Close()
172+
173+
// extract metadata
174+
var output bytes.Buffer
175+
err = Metadata(&output, f, network)
176+
require.NoError(t, err)
177+
178+
// verify output is valid YAML with expected fields
179+
var metadata CiphertextMetadata
180+
err = yaml.Unmarshal(output.Bytes(), &metadata)
181+
require.NoError(t, err)
182+
183+
// verify fields are populated
184+
require.NotZero(t, metadata.Round, "round should be non-zero")
185+
require.Equal(t, testnetQuicknetT, metadata.ChainHash, "chain hash should match")
186+
require.False(t, metadata.Time.IsZero(), "time should be set")
187+
188+
// verify the output contains the expected YAML keys
189+
outputStr := output.String()
190+
require.Contains(t, outputStr, "round:", "output should contain round field")
191+
require.Contains(t, outputStr, "chain_hash:", "output should contain chain_hash field")
192+
require.Contains(t, outputStr, "time:", "output should contain time field")
193+
require.Contains(t, outputStr, testnetQuicknetT, "output should contain the chain hash value")
194+
}

cmd/tle/commands/metadata.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package commands
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"io"
7+
"strconv"
8+
"strings"
9+
"time"
10+
11+
"filippo.io/age/armor"
12+
"gopkg.in/yaml.v3"
13+
14+
"github.com/drand/tlock/networks/http"
15+
)
16+
17+
type CiphertextMetadata struct {
18+
Round uint64 `yaml:"round"`
19+
ChainHash string `yaml:"chain_hash"`
20+
Time time.Time `yaml:"time"`
21+
}
22+
23+
// Metadata reads INPUT from src and, if it contains a tlock stanza, outputs YAML with round, chainhash and estimated time.
24+
func Metadata(dst io.Writer, src io.Reader, network *http.Network) error {
25+
rr := bufio.NewReader(src)
26+
27+
// Use armor.NewReader to handle armor decoding automatically
28+
// Only support armored input for metadata extraction in this change.
29+
armorReader := armor.NewReader(rr)
30+
31+
// Read from the de-armored content to find the tlock stanza
32+
scanner := bufio.NewScanner(armorReader)
33+
var round uint64
34+
var chainHash string
35+
found := false
36+
37+
for scanner.Scan() {
38+
line := strings.TrimSpace(scanner.Text())
39+
if strings.HasPrefix(line, "-> ") {
40+
fields := strings.Fields(line)
41+
if len(fields) >= 4 && fields[1] == "tlock" {
42+
r, err := strconv.ParseUint(fields[2], 10, 64)
43+
if err != nil {
44+
return fmt.Errorf("parse round: %w", err)
45+
}
46+
round = r
47+
chainHash = fields[3]
48+
found = true
49+
break
50+
}
51+
}
52+
}
53+
54+
if err := scanner.Err(); err != nil {
55+
return fmt.Errorf("read armored content: %w", err)
56+
}
57+
58+
if !found {
59+
return fmt.Errorf("no tlock stanza found in armored age header")
60+
}
61+
62+
// Estimate time for the given round
63+
now := time.Now()
64+
current := network.Current(now)
65+
var low, high time.Time
66+
if round <= current {
67+
high = now
68+
low = now.Add(-365 * 24 * time.Hour)
69+
} else {
70+
low = now
71+
high = now.Add(365 * 24 * time.Hour)
72+
}
73+
74+
t, err := roundToTimeBinarySearch(network, round, low, high)
75+
if err != nil {
76+
return fmt.Errorf("estimate time: %w", err)
77+
}
78+
79+
out := CiphertextMetadata{Round: round, ChainHash: chainHash, Time: t}
80+
b, err := yaml.Marshal(out)
81+
if err != nil {
82+
return fmt.Errorf("yaml marshal: %w", err)
83+
}
84+
if _, err := dst.Write(b); err != nil {
85+
return fmt.Errorf("write: %w", err)
86+
}
87+
return nil
88+
}
89+
90+
// roundToTimeBinarySearch searches for a time whose round is the target.
91+
func roundToTimeBinarySearch(network *http.Network, target uint64, low, high time.Time) (time.Time, error) {
92+
// If bounds are inverted, fix.
93+
if high.Before(low) {
94+
low, high = high, low
95+
}
96+
// Binary search with tolerance of 1 round.
97+
for i := 0; i < 64; i++ {
98+
mid := low.Add(high.Sub(low) / 2)
99+
r := network.RoundNumber(mid)
100+
if r == target {
101+
return mid, nil
102+
}
103+
if r < target {
104+
low = mid.Add(time.Second)
105+
} else {
106+
high = mid.Add(-time.Second)
107+
}
108+
if !high.After(low) {
109+
break
110+
}
111+
}
112+
// Best effort: return low as approximation.
113+
return low, nil
114+
}
115+
116+
// parseArgs tries to extract round and chain from a tlock stanza arguments slice.
117+
// (no other helpers)

cmd/tle/tle.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ func run() error {
7272

7373
switch {
7474
case flags.Metadata:
75-
err = tlock.New(network).Metadata(dst)
75+
if name := flag.Arg(0); name != "" && name != "-" {
76+
err = commands.Metadata(dst, src, network)
77+
} else {
78+
err = tlock.New(network).Metadata(dst)
79+
}
7680
case flags.Decrypt:
7781
err = tlock.New(network).Decrypt(dst, src)
7882
default:

0 commit comments

Comments
 (0)