Round utime times to integers.
[tpkg.git] / tpkg
1 #!/usr/bin/python3
2
3 import sys, os, bsddb3, struct, getopt, pwd, time, hashlib
4 bd = bsddb3.db
5 pj = os.path.join
6 deadlock = bd.DBLockDeadlockError
7 notfound = bd.DBNotFoundError
8
9 class txn(object):
10     def __init__(self, env, flags=bd.DB_TXN_WRITE_NOSYNC):
11         self.tx = env.txn_begin(None, flags)
12         self.env = env
13         self.done = False
14
15     def commit(self):
16         self.done = True
17         self.tx.commit(0)
18
19     def abort(self):
20         self.done = True
21         self.tx.abort()
22
23     def __enter__(self):
24         return self
25
26     def __exit__(self, etype, exc, tb):
27         if not self.done:
28             self.abort()
29         return False
30
31 class dbcursor(object):
32     def __init__(self, db, tx):
33         self.bk = db.cursor(txn=tx.tx)
34
35     def __enter__(self):
36         return self.bk
37
38     def __exit__(self, *args):
39         self.bk.close()
40         return False
41
42 def txnfun(envfun):
43     def fxf(fun):
44         def wrapper(self, *args, tx=None, **kwargs):
45             if tx is None:
46                 while True:
47                     try:
48                         with txn(envfun(self)) as ltx:
49                             ret = fun(self, *args, tx=ltx, **kwargs)
50                             ltx.commit()
51                             return ret
52                     except deadlock:
53                         continue
54             else:
55                 return fun(self, *args, tx=tx, **kwargs)
56         return wrapper
57     return fxf
58
59 class prefix(object):
60     use = None
61
62     def __init__(self, root, envdir):
63         self.root = root
64         self.envdir = envdir
65         self._env = None
66         self.dbs = {}
67
68     @property
69     def env(self):
70         if self._env is None:
71             if not os.path.isdir(self.envdir):
72                 sys.stderr.write("tpkg: creating %s...\n" % (self.envdir))
73                 os.makedirs(self.envdir)
74             env = bd.DBEnv()
75             env.set_lk_detect(bd.DB_LOCK_RANDOM)
76             fl = bd.DB_THREAD | bd.DB_INIT_MPOOL | bd.DB_INIT_LOCK | bd.DB_INIT_LOG | bd.DB_INIT_TXN | bd.DB_CREATE
77             mode = 0o666
78             env.open(self.envdir, fl, mode)
79             self._env = env
80         return self._env
81
82     def maint(self):
83         env = self._env
84         if env is not None:
85             env.txn_checkpoint(1024)
86             env.log_archive(bd.DB_ARCH_REMOVE)
87
88     @txnfun(lambda self: self.env)
89     def db(self, name, *, dup=False, tx):
90         if name not in self.dbs:
91             db = bd.DB(self.env)
92             if dup:
93                 db.set_flags(bd.DB_DUPSORT)
94             fl = bd.DB_THREAD | bd.DB_CREATE
95             mode = 0o666
96             db.open(name, None, bd.DB_BTREE, fl, mode, txn=tx.tx)
97             self.dbs[name] = db
98         return self.dbs[name]
99
100     def close(self):
101         if self._env is not None:
102             for db in self.dbs.values():
103                 db.close()
104             self._env.close()
105             self._env = None
106
107     def __del__(self):
108         self.close()
109
110     @txnfun(lambda self: self.env)
111     def unregfile(self, path, *, tx):
112         epath = path.encode("utf-8")
113         db = self.db("filedata")
114         if db.has_key(epath, txn=tx.tx):
115             db.delete(epath, txn=tx.tx)
116         db = self.db("file-pkg")
117         epkg = db.get(epath, None, txn=tx.tx)
118         if epkg is not None:
119             db.delete(epath, txn=tx.tx)
120             with dbcursor(self.db("pkg-file", dup=True), tx) as cur:
121                 try:
122                     cur.get_both(epkg, epath)
123                 except notfound:
124                     pass
125                 else:
126                     cur.delete()
127
128     @txnfun(lambda self: self.env)
129     def regfile(self, path, pkg, digest, *, tx):
130         epath, epkg = path.encode("utf-8"), pkg.encode("utf-8")
131         self.unregfile(path, tx=tx)
132         filedata = b"digest\0" + digest.encode("utf-8") + b"\0"
133         self.db("filedata").put(epath, filedata, txn=tx.tx)
134         self.db("file-pkg").put(epath, epkg, txn=tx.tx)
135         self.db("pkg-file", dup=True).put(epkg, epath, flags=bd.DB_NODUPDATA, txn=tx.tx)
136
137     @txnfun(lambda self: self.env)
138     def filedata(self, path, default=KeyError, *, tx):
139         epath = path.encode("utf-8")
140         data = self.db("filedata").get(epath, None, txn=tx.tx)
141         if data is None:
142             if default is KeyError:
143                 raise KeyError(path)
144             else:
145                 return default
146         data = data.split(b'\0')
147         if data[-1] != b"" or len(data) % 2 != 1:
148             raise Exception("invalid filedata")
149         ret = {}
150         for i in range(0, len(data) - 1, 2):
151             ret[data[i].decode("utf-8")] = data[i + 1].decode("utf-8")
152         return ret
153
154     @txnfun(lambda self: self.env)
155     def filepkg(self, path, default=KeyError, *, tx):
156         epath = path.encode("utf-8")
157         epkg = self.db("file-pkg").get(epath, None, txn=tx.tx)
158         if epkg is None:
159             if default is KeyError:
160                 raise KeyError(path)
161             else:
162                 return default
163         return epkg.decode("utf-8")
164
165     @txnfun(lambda self: self.env)
166     def pkgfiles(self, pkg, default=KeyError, *, tx):
167         epkg = pkg.encode("utf-8")
168         with dbcursor(self.db("pkg-file", dup=True), tx) as cur:
169             try:
170                 edat = cur.set(epkg)
171                 if edat is None:
172                     raise notfound()
173                 fpkg, epath = edat
174                 assert fpkg == epkg
175             except notfound:
176                 if default is KeyError:
177                     raise KeyError(pkg)
178                 else:
179                     return default
180             ret = []
181             while fpkg == epkg:
182                 ret.append(epath.decode("utf-8"))
183                 edat = cur.next()
184                 if edat is None:
185                     break
186                 fpkg, epath = edat
187         return ret
188
189     @classmethod
190     def home(cls):
191         home = pwd.getpwuid(os.getuid()).pw_dir
192         return cls(pj(home, "sys"), pj(home, ".tpkg/db"))
193
194     @classmethod
195     def test(cls):
196         home = pwd.getpwuid(os.getuid()).pw_dir
197         return cls(pj(home, "tpkgtest"), pj(home, ".tpkg/testdb"))
198
199     @classmethod
200     def local(cls):
201         return cls("/usr/local", "/usr/local/etc/tpkg/db")
202
203 class vfsfile(object):
204     def __init__(self, path, fullpath):
205         self.path = path
206         self.fullpath = fullpath
207
208     def open(self):
209         return open(self.fullpath, "rb")
210
211     def stat(self):
212         return os.stat(self.fullpath)
213
214 class vfspkg(object):
215     def __init__(self, root):
216         self.root = root
217
218     def __iter__(self):
219         def scan(lp, fp):
220             dpre = "" if (lp == "") else lp + "/"
221             for dent in os.scandir(fp):
222                 dpath = dpre + dent.name
223                 if dent.is_dir():
224                     yield from scan(dpath, dent.path)
225                 else:
226                     yield vfsfile(dpath, dent.path)
227         return scan("", self.root)
228
229 def copy(dst, src):
230     dig = hashlib.sha256()
231     while True:
232         buf = src.read(65536)
233         if buf == b"":
234             return dig.hexdigest().lower()
235         dst.write(buf)
236         dig.update(buf)
237
238 def digest(fp):
239     dig = hashlib.sha256()
240     while True:
241         buf = fp.read(65536)
242         if buf == b"":
243             return dig.hexdigest().lower()
244         dig.update(buf)
245
246 def install(pfx, pkg, pkgname):
247     for fl in pkg:
248         if os.path.exists(pj(pfx.root, fl.path)):
249             sys.stderr.write("tpkg: %s: already exists\n" % (fl.path))
250             sys.exit(1)
251     for fl in pkg:
252         tp = pj(pfx.root, fl.path)
253         tpdir = os.path.dirname(tp)
254         if not os.path.isdir(tpdir):
255             os.makedirs(tpdir)
256         tmpp = tp + ".tpkg-new"
257         sb = fl.stat()
258         try:
259             with open(tmpp, "wb") as ofp:
260                 os.fchmod(ofp.fileno(), sb.st_mode & 0o7777)
261                 with fl.open() as ifp:
262                     dig = copy(ofp, ifp)
263             pfx.regfile(fl.path, pkgname, dig)
264         except:
265             try:
266                 os.unlink(tmpp)
267             except FileNotFoundError:
268                 pass
269             raise
270         os.rename(tmpp, tp)
271         os.utime(tp, ns=(round(time.time() * 1e9), round(sb.st_mtime * 1e9)))
272
273 def uninstall(pfx, pkg):
274     for fn in pfx.pkgfiles(pkg):
275         fpath = pj(pfx.root, fn)
276         if not os.path.exists(fpath):
277             sys.stderr.write("tpkg: warning: %s does not exist\n" % (fn))
278         else:
279             fdat = pfx.filedata(fn)
280             with open(fpath, "rb") as fp:
281                 if digest(fp) != fdat.get("digest", ""):
282                     sys.stderr.write("tpkg: %s does not match registered hash\n" % (fn))
283                     sys.exit(1)
284     for fn in pfx.pkgfiles(pkg):
285         fpath = pj(pfx.root, fn)
286         try:
287             os.unlink(fpath)
288         except FileNotFoundError:
289             pass
290         pfx.unregfile(fn)
291
292 cmds = {}
293
294 def cmd_install(argv):
295     def usage(out):
296         out.write("usage: tpkg install [-n NAME] SOURCEDIR\n")
297     opts, args = getopt.getopt(argv, "n:")
298     pkgname = None
299     for o, a in opts:
300         if o == "-n":
301             pkgname = a
302     if len(args) < 1:
303         usage(sys.stderr)
304         sys.exit(1)
305     srcpath = args[0]
306     if not os.path.isdir(srcpath):
307         sys.stderr.write("tpkg: %s: not a directory\n" % (srcpath))
308         sys.exit(1)
309     if pkgname is None:
310         pkgname = os.path.basename(os.path.realpath(srcpath))
311     if not pkgname:
312         sys.stderr.write("tpkg: could not determine package name\n")
313         sys.exit(1)
314     install(prefix.use, vfspkg(srcpath), pkgname)
315 cmds["install"] = cmd_install
316
317 def cmd_uninstall(argv):
318     def usage(out):
319         out.write("usage: tpkg uninstall NAME\n")
320     opts, args = getopt.getopt(argv, "")
321     if len(args) < 1:
322         usage(sys.stderr)
323         sys.exit(1)
324     pkgname = args[0]
325     uninstall(prefix.use, pkgname)
326 cmds["uninstall"] = cmd_uninstall
327
328 def cmd_list(argv):
329     def usage(out):
330         out.write("usage: tpkg list NAME\n")
331     opts, args = getopt.getopt(argv, "")
332     if len(args) < 1:
333         usage(sys.stderr)
334         sys.exit(1)
335     pkgname = args[0]
336     try:
337         files = prefix.use.pkgfiles(pkgname)
338     except KeyError:
339         sys.stderr.write("tpkg: %s: no such package\n" % (pkgname))
340         sys.exit(1)
341     for fn in files:
342         sys.stdout.write("%s\n" % pj(prefix.use.root, fn))
343 cmds["list"] = cmd_list
344
345 def usage(file):
346     file.write("usage:\ttpkg help\n")
347 cmds["help"] = lambda argv: usage(sys.stdout)
348
349 def main(argv):
350     pfx = None
351     opts, args = getopt.getopt(argv, "hp:")
352     for o, a in opts:
353         if o == "-h":
354             usage(sys.stdout)
355             sys.exit(0)
356         elif o == "-p":
357             if a == "sys":
358                 pfx = prefix.home()
359             elif a == "local":
360                 pfx = prefix.local()
361             elif a == "test":
362                 pfx = prefix.test()
363             else:
364                 sys.stderr.write("tpkg: %s: undefined prefix\n" % (a))
365                 sys.exit(1)
366     if pfx is None:
367         sys.stderr.write("tpkg: no prefix specified\n")
368         sys.exit(1)
369     prefix.use = pfx
370     try:
371         if len(args) > 0 and args[0] in cmds:
372             cmds[args[0]](args[1:])
373             pfx.maint()
374             sys.exit(0)
375         else:
376             usage(sys.stderr)
377             sys.exit(1)
378     finally:
379         pfx.close()
380
381 if __name__ == "__main__":
382     main(sys.argv[1:])