]> Cypherpunks repositories - dsc.git/blob - dsc
ececf810aeb78f45fe0beb22fca36c3447d2ae870e07129fa2d20d9ebf017359
[dsc.git] / dsc
1 #!/usr/bin/env jimsh
2 # dsc -- damn small configuration manager
3 # Copyright (C) 2025-2026 Sergey Matveev <stargrave@stargrave.org>
4 #               2025-2026 Vladimir Bobrov
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, version 3 of the License.
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
17
18 if {$argc == 0} {
19     puts -nonewline stderr {Usage:
20     dsc list [-v] [prefix] -- list all available options
21     dsc add opt       -- add /*/ section
22     dsc del opt       -- delete /*/ section
23     dsc del ""        -- delete the whole configuration, useful for import
24     dsc has opt       -- check if specified /*/ option exists
25     dsc set opt value -- set option's value
26     dsc set opt ""    -- unset value, make it default one
27     dsc set opt <data -- set binary option's value
28     dsc get opt       -- get option's value, maybe default one
29     dsc get opt/*     -- list /*/ sections
30     dsc diff [prefix] -- show the difference between saved and stash
31     dsc revert opt    -- revert opt's configuration
32     dsc commit        -- commit (save) configuration
33     dsc export [prefix] >file.txtar -- export (whole by default) configuration
34     dsc import-check    <file.txtar -- verify configuration's checksum
35     dsc import-pipe     <file.txtar -- import configuration
36     dsc import           file.txtar -- verify and import configuration
37     dsc path opt      -- get full path to option's value (if set)
38     dsc apply prefix  -- apply configuration on given prefix
39     dsc csum <data    -- compute data's checksum
40
41 Environmental variables:
42     $DSC_SCHEMA -- path to the schema definition
43     $DSC_STASH  -- path to stashed/unsaved state
44     $DSC_SAVED  -- path to committed/saved state
45     $DSC_HASHERS -- "sha2-512 sha512" by default.
46                     Newline-separated list of "hash-name cmd" pairs
47
48 There are two kinds of options:
49     * array/list ones, which are identified with /*/ in "list"'s
50       output. "add", "del", "get opt/*" commands apply
51     * ordinary scalar ones, which can be "set", "get opt"
52 }
53     exit 1
54 }
55
56 set CopyBufLen [expr {128 * 1024}]
57 if {[info exists env(DSC_SCHEMA)]} {set Schema $env(DSC_SCHEMA)} {set Schema schema}
58 if {[info exists env(DSC_STASH)]} {set Stash $env(DSC_STASH)} {set Stash stash}
59 if {[info exists env(DSC_SAVED)]} {set Saved $env(DSC_SAVED)} {set Saved saved}
60 if {[info exists env(DSC_HASHERS)]} {
61     set Hashers [list]
62     foreach pair [split $env(DSC_HASHERS) "\n"] {
63         set idx [string first " " $pair]
64         if {$idx <= 0} {
65             continue
66         }
67         lappend Hashers \
68             [string range $pair 0 [expr {$idx-1}]] \
69             [string range $pair [expr {$idx+1}] end]
70     }
71 } {
72     set Hashers [list sha2-512 sha512]
73 }
74
75 proc readents {root typ} {
76     set ents [glob -directory $root -tails -nocomplain -- *]
77     set ents [lmap d $ents {if {[file type $root/$d] == $typ} {set d} {continue}}]
78     return [lsort $ents]
79 }
80
81 proc walk {root typ} {
82     set rv [list]
83     set root [string trimright $root /]
84     foreach s [readents $root $typ] {
85         lappend rv $root/$s
86         lappend rv {*}[walk $root/$s $typ]
87     }
88     return $rv
89 }
90
91 proc fileread {fn} {
92     set fh [open $fn]
93     set v [read $fh]
94     close $fh
95     return $v
96 }
97
98 proc find-opt-schema {opt} {
99     global Schema
100     set pth .
101     set opt [string trimright $opt /]
102     foreach e [split $opt /] {
103         if {[file exists $Schema/$pth/$e]} {
104             set pth $pth/$e
105             continue
106         }
107         if {[file exists $Schema/$pth/*]} {
108             set pth $pth/*
109             continue
110         }
111         puts stderr "can not find $opt in schema"
112         exit 1
113     }
114     return [string range $pth 2 end]
115 }
116
117 proc is-bin {opt} {
118     global Schema
119     return [file exists "$Schema/[find-opt-schema $opt]/bin"]
120 }
121
122 proc run-checker {opt v} {
123     global Schema
124     set fh [open [list "|$Schema/[find-opt-schema $opt]/check" $opt 2>@1 << $v]]
125     set v [$fh read]
126     if {[catch {close $fh}]} {
127         puts -nonewline stderr $v
128         exit 1
129     }
130     catch {untaint v}
131     return $v
132 }
133
134 proc assure-exists {opt} {
135     global Stash
136     if {![file exists $Stash/$opt]} {
137         puts stderr "not found"
138         exit 1
139     }
140 }
141
142 proc txtar-fn {line} {
143     if {
144         [string length $line] > 6 &&
145         [string range $line 0 2] == "-- " &&
146         [string range $line end-2 end] == " --"
147     } {
148         return [string range $line 3 end-3]
149     }
150     return ""
151 }
152
153 proc path-sanitize {fn} {
154     if {[string index $fn 0] == "/"} {
155         puts stderr "absolute paths are forbidden"
156         exit 1
157     }
158     foreach e [file split $fn] {
159         if {$e == ".."} {
160             puts stderr "relative paths are forbidden"
161             exit 1
162         }
163     }
164 }
165
166 proc assure-all-list-params-exist {opt {offset 0}} {
167     global Stash
168     set pth [find-opt-schema $opt]
169     set idx [lindex [lsearch -all [file split $pth] "*"] end-$offset]
170     if {$idx != ""} {
171         set pth [file join {*}[lrange [file split $opt] 0 $idx]]
172         if {![file exists $Stash/$pth]} {
173             puts stderr "$pth does not exist"
174             exit 1
175         }
176     }
177 }
178
179 set opt [lindex $argv 1]
180 switch [lindex $argv 0] {
181     list {
182         set verbose no
183         set prefix [lindex $argv 1]
184         if {$argc > 1 && [lindex $argv 1] == "-v"} {
185             set verbose yes
186             set prefix [lindex $argv 2]
187         }
188         foreach opt [walk $Schema directory] {
189             if {![file exists $opt/title]} {continue}
190             set name [string range $opt [expr {[string length $Schema] + 1}] end]
191             if {$prefix != ""} {
192                 if {[string range $name 0 [string length $prefix]-1] != $prefix} {
193                     continue
194                 }
195             }
196             set v [fileread $opt/title]
197             puts -nonewline "$name\t$v"
198             if {$verbose} {
199                 if {[file exists $opt/descr]} {
200                     set lines [split [fileread $opt/descr] "\n"]
201                     set lines [lrange $lines 0 end-1]
202                     foreach line $lines {
203                         puts "\t$line"
204                     }
205                 }
206             }
207         }
208     }
209     add {
210         assure-all-list-params-exist $opt 1
211         set dir [file dirname $opt]
212         set tail [run-checker $opt [file tail $opt]]
213         set tail [string trimright $tail "\n"]
214         file mkdir $Stash/$dir/$tail
215         puts $dir/$tail
216     }
217     del {
218         if {$opt != ""} {
219             assure-exists $opt
220         }
221         file delete -force $Stash/$opt
222     }
223     set {
224         assure-all-list-params-exist $opt
225         if {[llength $argv] > 2} {
226             set v [lindex $argv 2]
227             if {$v == ""} {
228                 file delete $Stash/$opt
229                 exit
230             }
231         }
232         file mkdir "$Stash/[file dirname $opt]"
233         if {[is-bin $opt]} {
234             set fh [open $Stash/$opt w]
235             fconfigure $fh -translation binary
236             fconfigure stdin -translation binary
237             stdin copyto $fh
238             close $fh
239         } else {
240             set v [run-checker $opt $v]
241             set fh [open $Stash/$opt w]
242             puts -nonewline $fh $v
243             close $fh
244         }
245     }
246     has {
247         assure-exists $opt
248     }
249     find-opt-schema {
250         puts [find-opt-schema $opt]
251     }
252     get {
253         if {[file tail $opt] == "*"} {
254             set opt [file dirname $opt]
255             if {![file exists $Stash/$opt]} {
256                 exit
257             }
258             foreach dir [readents $Stash/$opt directory] {
259                 puts $dir
260             }
261             exit
262         }
263         if {[file exists $Stash/$opt]} {
264             if {[is-bin $opt]} {
265                 set fh [open $Stash/$opt]
266                 fconfigure $fh -translation binary
267                 fconfigure stdout -translation binary
268                 $fh copyto stdout
269                 close $fh
270             } else {
271                 puts -nonewline [fileread $Stash/$opt]
272             }
273             exit
274         }
275         puts -nonewline [run-checker $opt ""]
276     }
277     apply {
278         set pth [find-opt-schema $opt]
279         if {[file exists $Schema/$pth/apply]} {
280             puts stderr "applying $opt..."
281             if {[catch {exec {*}[list "$Schema/$pth/apply" $opt] >@stdout 2>@stderr}]} {
282                 exit 1
283             }
284             exit
285         }
286         if {[file exists $Schema/$pth/*]} {
287             set rc 0
288             foreach dir [readents $Stash/$opt directory] {
289                 if {[catch {exec {*}[list dsc apply $opt/$dir] >@stdout 2>@stderr}]} {
290                     set rc 1
291                 }
292             }
293             exit $rc
294         }
295         foreach ent [readents $Schema/$opt directory] {
296             if {[catch {exec {*}[list dsc apply $opt/$ent] >@stdout 2>@stderr}]} {
297                 set rc 1
298             }
299         }
300     }
301     diff {
302         set dirsSaved [file tempfile dirsSaved.XXXXXX]
303         set fh [open $dirsSaved w]
304         foreach fn [walk $Saved/$opt directory] {
305             puts $fh [string range $fn [string length $Saved]+1 end]
306         }
307         close $fh
308         set dirsStash [file tempfile dirsStash.XXXXXX]
309         set fh [open $dirsStash w]
310         foreach fn [walk $Stash/$opt directory] {
311             puts $fh [string range $fn [string length $Stash]+1 end]
312         }
313         close $fh
314         set fh [open [list |diff -u -L dirs -L dirs $dirsSaved $dirsStash]]
315         puts -nonewline [read $fh]
316         catch {close $fh}
317         file delete $dirsSaved
318         file delete $dirsStash
319         set fh [open [list |diff -urN $Saved/$opt $Stash/$opt]]
320         set prefixSaved "--- $Saved/"
321         set prefixSavedLen [string length $prefixSaved]
322         set prefixStash "+++ $Stash/"
323         set prefixStashLen [string length $prefixStash]
324         while {[gets $fh line] >= 0} {
325             if {[string range $line 0 3] == "diff"} {
326                 continue
327             }
328             if {[string range $line 0 $prefixSavedLen-1] == $prefixSaved} {
329                 puts "--- [string range $line $prefixSavedLen end]"
330                 continue
331             }
332             if {[string range $line 0 $prefixStashLen-1] == $prefixStash} {
333                 puts "+++ [string range $line $prefixStashLen end]"
334                 continue
335             }
336             puts $line
337         }
338         catch {close $fh}
339     }
340     revert {
341         catch {file delete -force $Stash/$opt}
342         catch {exec {*}[list cp -a $Saved/$opt $Stash/$opt]}
343     }
344     commit {
345         file delete -force $Saved.bak
346         set tmp $Saved.[expr {int(rand() * 1000000)}]
347         exec {*}[list cp -a $Stash $tmp]
348         exec sync
349         file rename $Saved $Saved.bak
350         file rename $tmp $Saved
351         file delete -force $Saved.bak
352     }
353     csum {
354         set hsh [list]
355         foreach {name cmd} $Hashers {
356             lassign [pipe] r w
357             set fh [open [list |$cmd >@$w] w]
358             fconfigure $fh -translation binary
359             lappend hsh $name $fh $r $w
360         }
361         fconfigure stdin -translation binary
362         while {![eof stdin]} {
363             set buf [read stdin $CopyBufLen]
364             foreach {_ $fh _ _} $hsh {
365                 puts -nonewline $fh $buf
366             }
367         }
368         foreach {name fh r w} $hsh {
369             close $fh
370             close $w
371             gets $r v
372             set v [split $v " "]
373             puts "$name [lindex $v 0]"
374         }
375     }
376     export-raw {
377         set dirs [walk $Saved/$opt directory]
378         if {$opt != ""} {
379             set dirs [list $Saved/$opt {*}$dirs]
380         }
381         puts "-- .dirs --"
382         foreach fn $dirs {
383             puts [string range $fn [string length $Saved]+1 end]
384         }
385         foreach dir $dirs {
386             foreach fn [walk $dir file] {
387                 set sfn [string range $fn [string length $Saved]+1 end]
388                 if {[is-bin $sfn]} {
389                     puts "-- $sfn:base64 --"
390                     set fh [open "|base64 $fn"]
391                     $fh copyto stdout
392                     close $fh
393                 } else {
394                     puts "-- $sfn --"
395                     set fh [open $fn]
396                     while {[gets $fh line] >= 0} {
397                         if {[txtar-fn $line] != ""} {
398                             set line "-- $line --"
399                         }
400                         puts $line
401                     }
402                     close $fh
403                 }
404             }
405         }
406     }
407     export {
408         set exporter [open [list |$argv0 export-raw $opt]]
409         fconfigure $exporter -translation binary
410         lassign [pipe] r w
411         set hasher [open [list |$argv0 csum >@$w] w]
412         fconfigure $hasher -translation binary
413         fconfigure stdout -translation binary
414         while {![eof $exporter]} {
415             set buf [read $exporter $CopyBufLen]
416             puts -nonewline stdout $buf
417             puts -nonewline $hasher $buf
418         }
419         close $hasher
420         close $w
421         puts "-- .csum --"
422         puts -nonewline [read $r]
423     }
424     import-pipe {
425         fconfigure stdin -translation binary
426         while {[gets stdin line] >= 0} {
427             set fn [txtar-fn $line]
428             if {$fn != ""} {
429                 break
430             }
431         }
432         if {$fn == ".dirs"} {
433             while {[gets stdin line] >= 0} {
434                 set fn [txtar-fn $line]
435                 if {$fn == ""} {
436                     path-sanitize $line
437                     file mkdir $Stash/$line
438                 } else {
439                     break
440                 }
441             }
442         }
443         proc openfh {fn} {
444             path-sanitize $fn
445             set bin no
446             if {[string range $fn [expr {[string length $fn]-7}] end] == ":base64"} {
447                 set bin yes
448                 set fn [string range $fn 0 [expr {[string length $fn]-7-1}]]
449             }
450             global Stash
451             file mkdir [file dirname $Stash/$fn]
452             if {$bin} {
453                 set fh [open [list |base64 -d > $Stash/$fn] w]
454             } else {
455                 set fh [open $Stash/$fn w]
456                 fconfigure $fh -translation binary
457             }
458             return $fh
459         }
460         set fh [openfh $fn]
461         while {[gets stdin line] >= 0} {
462             set fn [txtar-fn $line]
463             if {$fn == ""} {
464                 puts $fh $line
465             } elseif {[string range $fn 0 2] == "-- "} {
466                 puts $fh $fn
467             } elseif {$fn == ".csum"} {
468                 break
469             } else {
470                 close $fh
471                 set fh [openfh $fn]
472             }
473         }
474         close $fh
475         exec sync
476     }
477     import-check {
478         lassign [pipe] r w
479         set hasher [open [list |$argv0 csum >@$w] w]
480         fconfigure $hasher -translation binary
481         fconfigure stdin -translation binary
482         while {[gets stdin line] >= 0} {
483             if {$line == "-- .csum --"} {
484                 break
485             }
486             puts $hasher $line
487         }
488         close $hasher
489         close $w
490         set got [read $r]
491         set exp [read stdin]
492         if {$got == $exp} {
493             exit 0
494         }
495         puts stderr "integrity failure"
496         puts -nonewline stderr $got
497         puts stderr "\t!="
498         puts -nonewline stderr $exp
499         exit 1
500     }
501     import {
502         exec $argv0 import-check <$opt
503         exec $argv0 import-pipe <$opt
504     }
505     path {
506         if {[file exists $Stash/$opt]} {
507             puts [file normalize $Stash/$opt]
508         }
509     }
510     default {
511         puts stderr "unknown cmd"
512         exit 1
513     }
514 }
515
516 # vim: ft=tcl