Skip to content

Commit 314efaf

Browse files
committed
Examples: Add repository application example
This uses the repository module to create an app that * generates everything from scratch * serves metadata and targets from memory * simulates a live repository by adding new targets every few seconds Signed-off-by: Jussi Kukkonen <jkukkonen@google.com>
1 parent 5e17617 commit 314efaf

2 files changed

Lines changed: 231 additions & 0 deletions

File tree

examples/repository/_simplerepo.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Copyright 2021-2022 python-tuf contributors
2+
# SPDX-License-Identifier: MIT OR Apache-2.0
3+
4+
"""Simple example of using the repository library to build a repository"""
5+
6+
import copy
7+
import logging
8+
from collections import defaultdict
9+
from datetime import datetime, timedelta
10+
from typing import Dict, List
11+
12+
from securesystemslib import keys
13+
from securesystemslib.signer import Signer, SSlibSigner
14+
15+
from tuf.api.metadata import (
16+
Key,
17+
Metadata,
18+
MetaFile,
19+
Root,
20+
Snapshot,
21+
TargetFile,
22+
Targets,
23+
Timestamp,
24+
)
25+
from tuf.repository import Repository
26+
27+
logger = logging.getLogger(__name__)
28+
29+
_signed_init = {
30+
Root.type: Root,
31+
Snapshot.type: Snapshot,
32+
Targets.type: Targets,
33+
Timestamp.type: Timestamp,
34+
}
35+
36+
37+
class SimpleRepository(Repository):
38+
"""Very simple in-memory repository implementation
39+
40+
This repository keeps the metadata for all versions of all roles in memory.
41+
It also keeps all target content in memory.
42+
43+
44+
Attributes:
45+
role_cache: Contains every historical metadata version of every role in
46+
this repositorys. Keys are rolenames and values are lists of
47+
Metadata
48+
signer_cache: Contains all signers available to the repository. Keys
49+
are rolenames, values are lists of signers
50+
target_cache:
51+
"""
52+
53+
expiry_period = timedelta(days=1)
54+
55+
def __init__(self) -> None:
56+
# all versions of all metadata
57+
self.role_cache: Dict[str, List[Metadata]] = defaultdict(list)
58+
# all current keys
59+
self.signer_cache: Dict[str, List[Signer]] = defaultdict(list)
60+
# all target content
61+
self.target_cache: Dict[str, bytes] = {}
62+
63+
# setup a basic repository, generate signing key per top-level role
64+
with self.edit("root", init=True) as root:
65+
for role in ["root", "timestamp", "snapshot", "targets"]:
66+
key = keys.generate_ed25519_key()
67+
self.signer_cache[role].append(SSlibSigner(key))
68+
root.add_key(Key.from_securesystemslib_key(key), role)
69+
70+
for role in ["timestamp", "snapshot", "targets"]:
71+
with self.edit(role, init=True):
72+
pass
73+
74+
def open(self, role: str, init: bool = False) -> Metadata:
75+
"""Return current Metadata for role from 'storage' (or create a new one)"""
76+
77+
if init:
78+
signed_init = _signed_init.get(role, Targets)
79+
md = Metadata(signed_init())
80+
81+
# this makes version bumping in close() simpler
82+
md.signed.version = 0
83+
return md
84+
85+
# return latest metadata from storage (but don't return a reference)
86+
return copy.deepcopy(self.role_cache[role][-1])
87+
88+
def close(self, role: str, md: Metadata, sign_only: bool = False) -> None:
89+
"""Store a version of metadata. Handle version bumps, expiry, signing"""
90+
if sign_only:
91+
for signer in self.signer_cache[role]:
92+
md.sign(signer, append=True)
93+
self.role_cache[role][-1] = md
94+
else:
95+
md.signed.version += 1
96+
md.signed.expires = datetime.utcnow() + self.expiry_period
97+
98+
md.signatures.clear()
99+
for signer in self.signer_cache[role]:
100+
md.sign(signer, append=True)
101+
102+
self.role_cache[role].append(md)
103+
104+
def add_target(self, path: str, content: str) -> None:
105+
"""Add a target to repository"""
106+
data = bytes(content, "utf-8")
107+
108+
# add content to cache for serving to clients
109+
self.target_cache[path] = data
110+
111+
# add a target in the targets metadata
112+
with self.edit("targets") as targets:
113+
targets.targets[path] = TargetFile.from_data(path, data)
114+
115+
logger.debug("Targets v%d", targets.version)
116+
117+
# update snapshot, timestamp
118+
meta = {"targets.json": MetaFile(targets.version)}
119+
new_version, _ = self.snapshot(meta)
120+
if new_version is not None:
121+
self.timestamp(MetaFile(new_version))

examples/repository/repo

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
#!/usr/bin/env python
2+
# Copyright 2021-2022 python-tuf contributors
3+
# SPDX-License-Identifier: MIT OR Apache-2.0
4+
5+
"""Simple repository example application
6+
7+
The application stores metadata and targets in memory, and serves them via http.
8+
* Keys are generated at startup
9+
* The application simulates a live reposittory by adding a new target every few seconds
10+
"""
11+
12+
import argparse
13+
import logging
14+
import sys
15+
from datetime import datetime
16+
from http.server import BaseHTTPRequestHandler, HTTPServer
17+
from time import time
18+
from typing import Dict, List
19+
20+
from _simplerepo import SimpleRepository
21+
22+
logger = logging.getLogger(__name__)
23+
24+
class ReqHandler(BaseHTTPRequestHandler):
25+
"""HTTP handler to serve metadata and targets from a SimpleRepository"""
26+
27+
def do_GET(self):
28+
if self.path.startswith("/metadata/") and self.path.endswith(".json"):
29+
self.get_metadata(self.path[len("/metadata/"):-len(".json")])
30+
elif self.path.startswith("/targets/"):
31+
self.get_target(self.path[len("/targets/"):])
32+
else:
33+
self.send_error(404, "Only serving /metadata/*.json")
34+
35+
def get_metadata(self, ver_and_role: str):
36+
repo = self.server.repo
37+
38+
ver_str, sep, role = ver_and_role.rpartition(".")
39+
if sep == "":
40+
# 0 will lead to list lookup with -1, meaning latest version
41+
ver = 0
42+
else:
43+
ver = int(ver_str)
44+
45+
if role not in repo.role_cache or ver > len(repo.role_cache[role]):
46+
self.send_error(404, f"Role {role} version {ver} not found")
47+
return
48+
49+
# send the metadata json
50+
data = repo.role_cache[role][ver-1].to_bytes()
51+
self.send_response(200)
52+
self.send_header('Content-length', len(data))
53+
self.end_headers()
54+
self.wfile.write(data)
55+
56+
def get_target(self, targetpath: str):
57+
repo: SimpleRepository = self.server.repo
58+
_hash, _, target = targetpath.partition(".")
59+
60+
if target not in repo.target_cache:
61+
self.send_error(404, f"target {targetpath} not found")
62+
return
63+
64+
# TODO: check that hash actually matches -- or use hash.targetpath as target_cache keys?
65+
66+
# send the target content
67+
data = repo.target_cache[target]
68+
self.send_response(200)
69+
self.send_header('Content-length', len(data))
70+
self.end_headers()
71+
self.wfile.write(data)
72+
73+
74+
class RepositoryServer(HTTPServer):
75+
def __init__(self, port: int):
76+
super().__init__(("127.0.0.1", port), ReqHandler)
77+
self.timeout = 1
78+
self.repo = SimpleRepository()
79+
80+
81+
def main(argv: List[str]) -> None:
82+
"""Example repository server"""
83+
84+
parser = argparse.ArgumentParser()
85+
parser.add_argument("-v", "--verbose", action="count")
86+
parser.add_argument("-p", "--port", type=int, default=8001)
87+
args, _ = parser.parse_known_args(argv)
88+
89+
level = logging.DEBUG if args.verbose else logging.INFO
90+
logging.basicConfig(level=level)
91+
92+
server = RepositoryServer(args.port)
93+
last_change = 0
94+
counter = 0
95+
96+
logger.info(f"Now serving. Root v1 at http://127.0.0.1:{server.server_port}/metadata/1.root.json")
97+
98+
while True:
99+
# Simulate a live repository: Add a new target file every few seconds
100+
if time() - last_change > 10:
101+
last_change = int(time())
102+
counter += 1
103+
content = str(datetime.fromtimestamp(last_change))
104+
server.repo.add_target(f"file{str(counter)}.txt", content)
105+
106+
server.handle_request()
107+
108+
109+
if __name__ == "__main__":
110+
main(sys.argv)

0 commit comments

Comments
 (0)