1// SPDX-License-Identifier: MIT
2
3package disk
4
5import (
6 "bufio"
7 "context"
8 "fmt"
9 "os"
10 "strings"
11
12 "bnema.dev/ermite-runner/internal/cmdrun"
13)
14
15// Mounter loop-mounts and unmounts scratch disks. It needs root (loop devices),
16// like the rest of the container mode.
17type Mounter struct {
18 Run cmdrun.Runner
19 // Mountinfo is /proc/self/mountinfo; tests point it at a fixture.
20 Mountinfo string
21}
22
23// Mount loop-mounts the ext4 file at dir (which must exist). The mount is
24// deliberately not nosuid/nodev: the nested dockerd keeps its overlay2 layers
25// here, and overlayfs opens the real files of a layer through the layer's own
26// mount, so device nodes and setuid binaries of container images would stop
27// working on a nodev/nosuid backing mount.
28func (m Mounter) Mount(ctx context.Context, file, dir string) error {
29 if _, err := m.Run.Run(ctx, nil, "mount", "-o", "loop,noatime", "-t", "ext4", file, dir); err != nil {
30 return fmt.Errorf("loop-mount scratch disk: %w", err)
31 }
32 return nil
33}
34
35// Unmount unmounts dir; the loop device is released with it (mount -o loop sets
36// autoclear). A directory that is not a mount point is not an error.
37func (m Mounter) Unmount(ctx context.Context, dir string) error {
38 mounted, err := m.IsMounted(dir)
39 if err != nil {
40 return err
41 }
42 if !mounted {
43 return nil
44 }
45 if _, err := m.Run.Run(ctx, nil, "umount", dir); err != nil {
46 return fmt.Errorf("unmount scratch disk: %w", err)
47 }
48 return nil
49}
50
51// IsMounted reports whether dir is a mount point.
52func (m Mounter) IsMounted(dir string) (bool, error) {
53 mp, err := m.MountPoints()
54 if err != nil {
55 return false, err
56 }
57 for _, p := range mp {
58 if p == dir {
59 return true, nil
60 }
61 }
62 return false, nil
63}
64
65// MountPoints lists every mount point of the mount namespace.
66func (m Mounter) MountPoints() ([]string, error) {
67 path := m.Mountinfo
68 if path == "" {
69 path = "/proc/self/mountinfo"
70 }
71 f, err := os.Open(path)
72 if err != nil {
73 return nil, err
74 }
75 defer func() { _ = f.Close() }()
76 var out []string
77 sc := bufio.NewScanner(f)
78 sc.Buffer(make([]byte, 64<<10), 1<<20)
79 for sc.Scan() {
80 if mp, ok := mountPointOf(sc.Text()); ok {
81 out = append(out, mp)
82 }
83 }
84 return out, sc.Err()
85}
86
87// mountPointOf extracts the mount point (5th field) of a mountinfo line, with
88// the kernel's octal escapes (\040 for a space) decoded.
89func mountPointOf(line string) (string, bool) {
90 f := strings.Fields(line)
91 if len(f) < 5 {
92 return "", false
93 }
94 return unescapeMountinfo(f[4]), true
95}
96
97func unescapeMountinfo(s string) string {
98 if !strings.Contains(s, `\`) {
99 return s
100 }
101 var b strings.Builder
102 for i := 0; i < len(s); i++ {
103 if s[i] == '\\' && i+3 < len(s) {
104 if v, ok := octal3(s[i+1 : i+4]); ok {
105 b.WriteByte(v)
106 i += 3
107 continue
108 }
109 }
110 b.WriteByte(s[i])
111 }
112 return b.String()
113}
114
115func octal3(s string) (byte, bool) {
116 if len(s) != 3 {
117 return 0, false
118 }
119 var v int
120 for _, c := range s {
121 if c < '0' || c > '7' {
122 return 0, false
123 }
124 v = v*8 + int(c-'0')
125 }
126 return byte(v), v < 256
127}